Add model evaluation, explainability, text lab, and working demo samples

#1
ai-backend/eval_model.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ eval_model.py — NLP Model Evaluation for Dark Patterns Detector
3
+ ================================================================
4
+ Drop this file into the ai-backend/ folder alongside dataset.csv and run:
5
+
6
+ pip install scikit-learn pandas matplotlib seaborn
7
+ python eval_model.py
8
+
9
+ Outputs:
10
+ • Classification report (precision / recall / F1 per class)
11
+ • Overall accuracy
12
+ • Confusion matrix saved as confusion_matrix.png
13
+ • Model comparison table (baseline vs improved TF-IDF settings)
14
+ """
15
+
16
+ import pandas as pd
17
+ import matplotlib
18
+ matplotlib.use("Agg") # headless — no display needed
19
+ import matplotlib.pyplot as plt
20
+ import seaborn as sns
21
+ import os, sys
22
+
23
+ from sklearn.feature_extraction.text import TfidfVectorizer
24
+ from sklearn.linear_model import LogisticRegression
25
+ from sklearn.pipeline import make_pipeline
26
+ from sklearn.model_selection import train_test_split, cross_val_score
27
+ from sklearn.metrics import (
28
+ classification_report, confusion_matrix, accuracy_score
29
+ )
30
+
31
+ # ── Load dataset ──────────────────────────────────────────────────────
32
+ script_dir = os.path.dirname(os.path.abspath(__file__))
33
+ dataset_path = os.path.join(script_dir, "dataset.csv")
34
+
35
+ if not os.path.exists(dataset_path):
36
+ print("❌ dataset.csv not found. Make sure this script is in ai-backend/")
37
+ sys.exit(1)
38
+
39
+ df = pd.read_csv(dataset_path).dropna(subset=["text", "Pattern Category"])
40
+ X = df["text"]
41
+ y = df["Pattern Category"]
42
+
43
+ print(f"✅ Loaded {len(df)} samples across {y.nunique()} classes\n")
44
+ print("Class distribution:")
45
+ print(y.value_counts().to_string())
46
+ print()
47
+
48
+ # ── Train / test split ────────────────────────────────────────────────
49
+ X_train, X_test, y_train, y_test = train_test_split(
50
+ X, y, test_size=0.20, random_state=42, stratify=y
51
+ )
52
+
53
+ # ── Model 1: Baseline (as in production) ─────────────────────────────
54
+ model_baseline = make_pipeline(
55
+ TfidfVectorizer(ngram_range=(1, 2)),
56
+ LogisticRegression(C=10.0, class_weight="balanced", max_iter=1000)
57
+ )
58
+ model_baseline.fit(X_train, y_train)
59
+ y_pred_baseline = model_baseline.predict(X_test)
60
+
61
+ # ── Model 2: Improved (sublinear TF scaling + min_df pruning) ─────────
62
+ model_improved = make_pipeline(
63
+ TfidfVectorizer(
64
+ ngram_range=(1, 3),
65
+ sublinear_tf=True,
66
+ min_df=2,
67
+ max_features=50_000,
68
+ ),
69
+ LogisticRegression(C=5.0, class_weight="balanced", max_iter=1000, solver="saga")
70
+ )
71
+ model_improved.fit(X_train, y_train)
72
+ y_pred_improved = model_improved.predict(X_test)
73
+
74
+ # ── Print reports ─────────────────────────────────────────────────────
75
+ print("=" * 65)
76
+ print("BASELINE MODEL — TF-IDF(1,2) + LogReg(C=10)")
77
+ print("=" * 65)
78
+ print(f"Accuracy: {accuracy_score(y_test, y_pred_baseline):.4f}\n")
79
+ print(classification_report(y_test, y_pred_baseline))
80
+
81
+ print("=" * 65)
82
+ print("IMPROVED MODEL — TF-IDF(1,3, sublinear) + LogReg(C=5, saga)")
83
+ print("=" * 65)
84
+ print(f"Accuracy: {accuracy_score(y_test, y_pred_improved):.4f}\n")
85
+ print(classification_report(y_test, y_pred_improved))
86
+
87
+ # ── 5-fold cross-validation ───────────────────────────────────────────
88
+ cv_baseline = cross_val_score(model_baseline, X, y, cv=5, scoring="accuracy")
89
+ cv_improved = cross_val_score(model_improved, X, y, cv=5, scoring="accuracy")
90
+
91
+ print("5-Fold Cross-Validation Accuracy:")
92
+ print(f" Baseline : {cv_baseline.mean():.4f} ± {cv_baseline.std():.4f}")
93
+ print(f" Improved : {cv_improved.mean():.4f} ± {cv_improved.std():.4f}")
94
+ print()
95
+
96
+ # ── Confusion matrix plot ─────────────────────────────────────────────
97
+ labels = sorted(y.unique())
98
+ cm = confusion_matrix(y_test, y_pred_improved, labels=labels)
99
+
100
+ fig, ax = plt.subplots(figsize=(10, 8))
101
+ sns.heatmap(
102
+ cm,
103
+ annot=True, fmt="d", cmap="Blues",
104
+ xticklabels=labels, yticklabels=labels,
105
+ linewidths=0.5, linecolor="white",
106
+ ax=ax
107
+ )
108
+ ax.set_title("Confusion Matrix — Improved Model", fontsize=14, fontweight="bold", pad=14)
109
+ ax.set_ylabel("True Label", fontsize=12)
110
+ ax.set_xlabel("Predicted Label", fontsize=12)
111
+ plt.xticks(rotation=30, ha="right", fontsize=9)
112
+ plt.yticks(rotation=0, fontsize=9)
113
+ plt.tight_layout()
114
+
115
+ out_path = os.path.join(script_dir, "confusion_matrix.png")
116
+ plt.savefig(out_path, dpi=150)
117
+ print(f"📊 Confusion matrix saved → {out_path}")
118
+
119
+ # ── Top features per class ────────────────────────────────────────────
120
+ print("\n── Top 8 TF-IDF features per class (Improved Model) ──")
121
+ tfidf = model_improved.named_steps["tfidfvectorizer"]
122
+ logreg = model_improved.named_steps["logisticregression"]
123
+ feat_names = tfidf.get_feature_names_out()
124
+
125
+ for i, cls in enumerate(logreg.classes_):
126
+ top_idx = logreg.coef_[i].argsort()[-8:][::-1]
127
+ top_feats = [feat_names[j] for j in top_idx]
128
+ print(f" {cls:<20}: {', '.join(top_feats)}")
129
+
130
+ print("\n✅ Evaluation complete.")
ai-backend/generate_samples.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate synthetic fintech UI screenshots containing dark-pattern copy.
2
+
3
+ The screenshots are plain rendered UI with real text so Tesseract can read
4
+ them and the classifier has something genuine to detect. Run once:
5
+
6
+ python generate_samples.py
7
+
8
+ Outputs PNGs into ./samples/ (served by Flask at /api/samples/<name>).
9
+ """
10
+ from PIL import Image, ImageDraw, ImageFont
11
+ import os
12
+
13
+ OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "samples")
14
+ os.makedirs(OUT_DIR, exist_ok=True)
15
+
16
+ W, H = 900, 640
17
+ BG = (248, 249, 252)
18
+ CARD = (255, 255, 255)
19
+ INK = (24, 28, 40)
20
+ MUTED = (110, 118, 135)
21
+ ACCENT = (79, 70, 229)
22
+ DANGER = (220, 53, 69)
23
+ WARN_BG = (255, 243, 205)
24
+ WARN_INK = (133, 100, 4)
25
+ GREEN = (25, 135, 84)
26
+
27
+
28
+ def font(size, bold=False):
29
+ names = ["arialbd.ttf"] if bold else ["arial.ttf"]
30
+ for name in names + ["DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"]:
31
+ try:
32
+ return ImageFont.truetype(name, size)
33
+ except OSError:
34
+ continue
35
+ return ImageFont.load_default()
36
+
37
+
38
+ def card(d, x0, y0, x1, y1, fill=CARD, outline=(225, 228, 235)):
39
+ d.rounded_rectangle([x0, y0, x1, y1], radius=12, fill=fill, outline=outline, width=1)
40
+
41
+
42
+ def button(d, x0, y0, x1, y1, label, fill=ACCENT, ink=(255, 255, 255), size=20, bold=True):
43
+ d.rounded_rectangle([x0, y0, x1, y1], radius=8, fill=fill)
44
+ f = font(size, bold)
45
+ tw = d.textlength(label, font=f)
46
+ d.text(((x0 + x1 - tw) / 2, (y0 + y1) / 2 - size / 2 - 2), label, font=f, fill=ink)
47
+
48
+
49
+ def make_checkout():
50
+ img = Image.new("RGB", (W, H), BG)
51
+ d = ImageDraw.Draw(img)
52
+ d.rectangle([0, 0, W, 64], fill=(30, 34, 50))
53
+ d.text((30, 20), "PayVault | Secure Checkout", font=font(22, True), fill=(255, 255, 255))
54
+
55
+ card(d, 30, 90, 560, 600)
56
+ d.text((55, 110), "Premium Trading Plan", font=font(26, True), fill=INK)
57
+ d.text((55, 150), "$29.99 / month", font=font(22), fill=MUTED)
58
+
59
+ d.rounded_rectangle([55, 195, 535, 245], radius=8, fill=WARN_BG)
60
+ d.text((70, 207), "Hurry! Offer expires in 04:59 minutes", font=font(20, True), fill=WARN_INK)
61
+
62
+ d.rounded_rectangle([55, 260, 535, 310], radius=8, fill=(253, 232, 232))
63
+ d.text((70, 272), "Only 2 spots left at this price!", font=font(20, True), fill=DANGER)
64
+
65
+ d.text((55, 335), "17 people are viewing this offer right now", font=font(18), fill=MUTED)
66
+ d.text((55, 370), "1,358 traders upgraded this week", font=font(18), fill=MUTED)
67
+
68
+ button(d, 55, 430, 535, 485, "CLAIM MY DISCOUNT NOW")
69
+ d.text((150, 510), "No thanks, I prefer losing money", font=font(15), fill=(180, 184, 195))
70
+
71
+ card(d, 590, 90, 870, 600)
72
+ d.text((615, 110), "Order Summary", font=font(20, True), fill=INK)
73
+ d.text((615, 155), "Plan", font=font(16), fill=MUTED)
74
+ d.text((800, 155), "$29.99", font=font(16), fill=INK)
75
+ d.text((615, 185), "Priority support", font=font(16), fill=MUTED)
76
+ d.text((800, 185), "$4.99", font=font(16), fill=INK)
77
+ d.line([615, 225, 845, 225], fill=(225, 228, 235), width=1)
78
+ d.text((615, 240), "Total today", font=font(18, True), fill=INK)
79
+ d.text((790, 240), "$34.98", font=font(18, True), fill=INK)
80
+ img.save(os.path.join(OUT_DIR, "checkout_urgency.png"))
81
+
82
+
83
+ def make_cancellation():
84
+ img = Image.new("RGB", (W, H), BG)
85
+ d = ImageDraw.Draw(img)
86
+ d.rectangle([0, 0, W, 64], fill=(30, 34, 50))
87
+ d.text((30, 20), "CoinNest | Account Settings", font=font(22, True), fill=(255, 255, 255))
88
+
89
+ card(d, 150, 110, 750, 560)
90
+ d.text((185, 140), "Wait! Before you go...", font=font(28, True), fill=INK)
91
+ d.text((185, 195), "Are you sure you want to cancel? You will lose all", font=font(19), fill=INK)
92
+ d.text((185, 225), "your rewards and your exclusive member benefits", font=font(19), fill=INK)
93
+ d.text((185, 255), "will be gone forever.", font=font(19), fill=INK)
94
+
95
+ d.text((185, 305), "93% of members regret cancelling within a month", font=font(17), fill=MUTED)
96
+
97
+ button(d, 185, 360, 715, 415, "KEEP MY BENEFITS", fill=GREEN)
98
+ button(d, 185, 430, 715, 480, "Remind me later", fill=(235, 237, 242), ink=MUTED, bold=False)
99
+ d.text((340, 505), "continue to cancellation step 1 of 6", font=font(13), fill=(195, 198, 207))
100
+ img.save(os.path.join(OUT_DIR, "cancel_confirmshaming.png"))
101
+
102
+
103
+ def make_signup():
104
+ img = Image.new("RGB", (W, H), BG)
105
+ d = ImageDraw.Draw(img)
106
+ d.rectangle([0, 0, W, 64], fill=(30, 34, 50))
107
+ d.text((30, 20), "LoanLeap | Create Account", font=font(22, True), fill=(255, 255, 255))
108
+
109
+ card(d, 120, 100, 780, 580)
110
+ d.text((155, 125), "You're almost done!", font=font(26, True), fill=INK)
111
+
112
+ def checkbox(y, label, sub, checked=True):
113
+ d.rounded_rectangle([155, y, 179, y + 24], radius=5,
114
+ fill=ACCENT if checked else CARD,
115
+ outline=(200, 203, 212), width=1)
116
+ if checked:
117
+ d.line([160, y + 12, 166, y + 18], fill=(255, 255, 255), width=3)
118
+ d.line([166, y + 18, 175, y + 6], fill=(255, 255, 255), width=3)
119
+ d.text((195, y), label, font=font(18, True), fill=INK)
120
+ d.text((195, y + 28), sub, font=font(15), fill=MUTED)
121
+
122
+ checkbox(180, "Add Payment Protection Plus (+$9.99/mo)",
123
+ "Automatically added to protect your transactions")
124
+ checkbox(255, "Enroll in Premium Credit Monitoring (+$14.99/mo)",
125
+ "Free trial converts to paid plan after 7 days")
126
+ checkbox(330, "Share my data with trusted marketing partners",
127
+ "By continuing you agree to receive personalised offers")
128
+
129
+ d.text((155, 410), "By clicking Continue you accept the Terms, the Fee Schedule,", font=font(14), fill=MUTED)
130
+ d.text((155, 432), "and authorise recurring charges to your saved card.", font=font(14), fill=MUTED)
131
+
132
+ button(d, 155, 475, 745, 530, "CONTINUE")
133
+ img.save(os.path.join(OUT_DIR, "signup_sneaking.png"))
134
+
135
+
136
+ if __name__ == "__main__":
137
+ make_checkout()
138
+ make_cancellation()
139
+ make_signup()
140
+ print(f"Saved 3 sample screenshots to {OUT_DIR}")
ai-backend/samples/cancel_confirmshaming.png ADDED
ai-backend/samples/checkout_urgency.png ADDED
ai-backend/samples/signup_sneaking.png ADDED
ai-backend/server.py CHANGED
@@ -1,943 +1,1081 @@
1
- from flask import Flask, request, jsonify
2
- from flask_cors import CORS
3
- import pandas as pd
4
- from sklearn.feature_extraction.text import TfidfVectorizer
5
- from sklearn.linear_model import LogisticRegression
6
- from sklearn.pipeline import make_pipeline
7
- import pytesseract
8
- from PIL import Image
9
- import requests
10
- from io import BytesIO
11
- import base64
12
- import re
13
- import datetime
14
- import os
15
-
16
- script_dir = os.path.dirname(os.path.abspath(__file__))
17
- dist_dir = os.path.abspath(os.path.join(script_dir, '../dist'))
18
-
19
- if os.path.exists(dist_dir):
20
- print(f"📦 Serving static files from production build: {dist_dir}")
21
- app = Flask(__name__, static_folder=dist_dir, static_url_path='/')
22
- else:
23
- print("🧪 Running in development API mode (no dist folder found)")
24
- app = Flask(__name__)
25
-
26
- # Enable CORS for all API paths
27
- CORS(app, resources={r"/api/*": {"origins": "*"}})
28
-
29
- print("🧠 Loading NLP model from dataset.csv...")
30
- try:
31
- script_dir = os.path.dirname(os.path.abspath(__file__))
32
- dataset_path = os.path.join(script_dir, 'dataset.csv')
33
- df = pd.read_csv(dataset_path)
34
- df = df.dropna(subset=['text', 'Pattern Category'])
35
- model = make_pipeline(
36
- TfidfVectorizer(ngram_range=(1, 2)),
37
- LogisticRegression(C=10.0, class_weight='balanced', max_iter=1000)
38
- )
39
- model.fit(df['text'], df['Pattern Category'])
40
- print("✅ AI Model ready and trained!")
41
- except Exception as e:
42
- print(f" Error loading dataset: {e}")
43
-
44
- print("📉 Loading Financial Distress model from Financial Distress.csv...")
45
- distress_model = None
46
- try:
47
- distress_path = os.path.join(script_dir, 'Financial Distress.csv')
48
- distress_df = pd.read_csv(distress_path)
49
-
50
- # Financial Distress target value <= -0.5 is distress (class 1), else healthy (class 0)
51
- features_cols = [f'x{i}' for i in range(1, 84)]
52
- distress_df = distress_df.dropna(subset=['Financial Distress'] + features_cols)
53
-
54
- X_distress = distress_df[features_cols]
55
- y_distress = (distress_df['Financial Distress'] <= -0.5).astype(int)
56
-
57
- distress_model = LogisticRegression(max_iter=1000)
58
- distress_model.fit(X_distress, y_distress)
59
- print("✅ Financial Distress Model ready and trained!")
60
- except Exception as e:
61
- print(f"❌ Error loading Financial Distress dataset: {e}")
62
-
63
- print("📰 Loading Reddit News from RedditNews.csv...")
64
- news_list = []
65
- try:
66
- news_path = os.path.join(script_dir, 'RedditNews.csv')
67
- news_df = pd.read_csv(news_path)
68
- news_df = news_df.dropna(subset=['News'])
69
- news_list = news_df.to_dict(orient='records')
70
- print(f"✅ Loaded {len(news_list)} news headlines successfully!")
71
- except Exception as e:
72
- print(f"❌ Error loading RedditNews dataset: {e}")
73
-
74
- def analyze_headline_sentiment(news_text):
75
- pos_words = ["gain", "rise", "success", "profit", "win", "high", "positive", "growth", "launch", "heal", "benefit", "good", "strong", "advance", "recover", "save", "safe"]
76
- neg_words = ["fail", "drop", "loss", "crash", "investigate", "lawsuit", "down", "recession", "decrease", "kill", "death", "protest", "strike", "bad", "weak", "decline", "default", "scandal", "abuse", "murder", "hurt", "risk"]
77
-
78
- text_lower = news_text.lower()
79
- pos_score = sum(1 for word in pos_words if word in text_lower)
80
- neg_score = sum(1 for word in neg_words if word in text_lower)
81
-
82
- if pos_score > neg_score:
83
- return "positive"
84
- elif neg_score > pos_score:
85
- return "negative"
86
- else:
87
- return "neutral"
88
-
89
- def get_stock_news(symbol):
90
- symbol = symbol.upper()
91
- keywords = {
92
- "AAPL": ["apple", "iphone", "macbook", "ipad", "jobs", "tech"],
93
- "NVDA": ["chip", "nvidia", "gpu", "ai", "intel", "amd", "tech"],
94
- "TSLA": ["tesla", "elon", "musk", "electric", "battery", "car"],
95
- "COIN": ["bitcoin", "crypto", "blockchain", "exchange", "coinbase", "sec"],
96
- "MSFT": ["microsoft", "windows", "azure", "cloud", "tech", "gates"],
97
- "GOOGL": ["google", "alphabet", "search", "youtube", "android", "tech"],
98
- }
99
-
100
- stock_kws = keywords.get(symbol, [symbol.lower(), "market", "economy", "finance", "stocks", "trade", "shares"])
101
-
102
- matching = []
103
- for item in news_list:
104
- news_text = str(item['News'])
105
- text_lower = news_text.lower()
106
- if any(kw in text_lower for kw in stock_kws):
107
- matching.append(item)
108
- if len(matching) >= 100:
109
- break
110
-
111
- if len(matching) < 5:
112
- general_kws = ["market", "economy", "finance", "stocks", "trade", "shares"]
113
- for item in news_list:
114
- news_text = str(item['News'])
115
- text_lower = news_text.lower()
116
- if any(kw in text_lower for kw in general_kws):
117
- matching.append(item)
118
- if len(matching) >= 100:
119
- break
120
-
121
- formatted_news = []
122
- positive_count = 0
123
- negative_count = 0
124
-
125
- # We want a mix of headlines (e.g. 6 headlines)
126
- selected_items = matching[:6]
127
- if len(selected_items) < 6:
128
- selected_items = news_list[:6]
129
-
130
- for item in selected_items:
131
- headline = str(item['News'])
132
- date = str(item['Date'])
133
- sentiment = analyze_headline_sentiment(headline)
134
-
135
- if sentiment == "positive":
136
- positive_count += 1
137
- elif sentiment == "negative":
138
- negative_count += 1
139
-
140
- formatted_news.append({
141
- "headline": headline,
142
- "date": date,
143
- "sentiment": sentiment
144
- })
145
-
146
- total_val = positive_count + negative_count
147
- if total_val > 0:
148
- sentiment_pct = round((positive_count / total_val) * 100)
149
- else:
150
- # A deterministic fallback sentiment based on symbol hash
151
- hash_val = sum(ord(c) for c in symbol)
152
- sentiment_pct = 40 + (hash_val % 30) # 40% to 70% positive
153
-
154
- return {
155
- "articles": formatted_news,
156
- "sentimentPercent": sentiment_pct
157
- }
158
-
159
- def get_distress_risk(symbol):
160
- if distress_model is None:
161
- return {"riskLevel": "Low", "distressProbability": 15.0, "rawDistressScore": 0.05, "isDistressed": False}
162
-
163
- symbol = symbol.upper()
164
- try:
165
- script_dir = os.path.dirname(os.path.abspath(__file__))
166
- distress_path = os.path.join(script_dir, 'Financial Distress.csv')
167
- distress_df = pd.read_csv(distress_path)
168
-
169
- # Filter rows to select distressed vs healthy for demo consistency
170
- distressed_rows = distress_df[distress_df['Financial Distress'] <= -0.5]
171
- healthy_rows = distress_df[distress_df['Financial Distress'] > 0.5]
172
-
173
- if len(distressed_rows) == 0 or len(healthy_rows) == 0:
174
- return {"riskLevel": "Low", "distressProbability": 10.0, "rawDistressScore": 0.1, "isDistressed": False}
175
-
176
- # Deterministic row selection based on symbol hash
177
- hash_val = sum(ord(c) for c in symbol)
178
-
179
- # Override specific symbols for demonstration purposes:
180
- if symbol == 'COIN':
181
- # Map COIN to a distressed row
182
- row = distressed_rows.iloc[hash_val % len(distressed_rows)]
183
- elif symbol in ['AAPL', 'NVDA', 'MSFT', 'GOOGL']:
184
- # Map healthy tech to healthy row
185
- row = healthy_rows.iloc[hash_val % len(healthy_rows)]
186
- else:
187
- # Map deterministically from entire dataset
188
- row = distress_df.iloc[hash_val % len(distress_df)]
189
-
190
- features_cols = [f'x{i}' for i in range(1, 84)]
191
- features = row[features_cols].values.reshape(1, -1)
192
-
193
- prob = distress_model.predict_proba(features)[0][1] # probability of class 1 (distress)
194
- is_distressed = bool(distress_model.predict(features)[0] == 1)
195
-
196
- # Define risk levels:
197
- if prob > 0.6 or is_distressed:
198
- risk_level = "High"
199
- elif prob > 0.25:
200
- risk_level = "Medium"
201
- else:
202
- risk_level = "Low"
203
-
204
- raw_score = float(row['Financial Distress'])
205
-
206
- return {
207
- "riskLevel": risk_level,
208
- "distressProbability": round(float(prob) * 100, 1),
209
- "rawDistressScore": round(raw_score, 3),
210
- "isDistressed": is_distressed
211
- }
212
- except Exception as e:
213
- print(f"Error evaluating distress risk for {symbol}: {e}")
214
- return {"riskLevel": "Low", "distressProbability": 15.0, "rawDistressScore": 0.1, "isDistressed": False}
215
-
216
- def get_severity(prediction):
217
- severity_map = {
218
- "Urgency": "high",
219
- "Scarcity": "medium",
220
- "Social Proof": "low",
221
- "Misdirection": "high",
222
- "Obstruction": "critical",
223
- "Sneaking": "critical",
224
- "Forced Action": "critical"
225
- }
226
- return severity_map.get(prediction, "medium")
227
-
228
- def get_compliance_metadata(prediction, text):
229
- if prediction == "Urgency":
230
- violation = "Urgency tactics create artificial pressure to force immediate transaction decisions, potentially violating 12 CFR 1041 prohibiting deceptive acts or practices."
231
- recommendation = f"Remove countdown timers or false urgency text like '{text}'."
232
- elif prediction == "Scarcity":
233
- violation = "Scarcity tactics (e.g. artificial stock limits) manipulate consumers into immediate purchases, violating FTC Act Section 5 against deceptive practices."
234
- recommendation = f"Ensure the statement '{text}' is backed by real-time inventory systems. If not verified, remove it."
235
- elif prediction == "Social Proof":
236
- violation = "Unverified social proof notifications (e.g. 'X bought this recently') can mislead consumers, violating general rules on deceptive advertisements."
237
- recommendation = f"Validate that '{text}' is based on genuine user activity. Otherwise, disable this alert."
238
- elif prediction == "Misdirection":
239
- violation = "Misdirection visual/language design (like confirmshaming) steers users away from their intended choices, violating consumer choice principles."
240
- recommendation = f"Change the option text in '{text}' to use clear and neutral language (e.g. 'Cancel' / 'Confirm') without guilt-tripping."
241
- elif prediction == "Obstruction":
242
- violation = "Obstruction (making cancellation or opt-out complex) violates EFTA and CFPB guidelines against hard-to-cancel billing structures."
243
- recommendation = f"Simplify subscription cancellation related to '{text}'. The exit path should be as simple as the sign-up path."
244
- elif prediction == "Sneaking":
245
- violation = "Sneaking (adding hidden costs or pre-selected add-ons) violates EFTA and deceptive practices rules by charging without active consent."
246
- recommendation = f"Ensure '{text}' does not lead to pre-checked options. Require explicit opt-in for all additional items or services."
247
- elif prediction == "Forced Action":
248
- violation = "Forced Action requires consumers to perform unrelated actions (e.g. consent to tracking) to finish a task, violating consumer choice guidelines."
249
- recommendation = f"Allow users to proceed past '{text}' without mandatory signups or sharing non-essential data."
250
- else:
251
- violation = "General deceptive pattern detected that may violate CFPB guidelines against deceptive acts or practices."
252
- recommendation = "Redesign copy and flow to maximize user transparency and choice."
253
-
254
- return violation, recommendation
255
-
256
- @app.route('/api/analyze', methods=['POST', 'OPTIONS'])
257
- def analyze_image():
258
- if request.method == 'OPTIONS':
259
- return jsonify({}), 200
260
-
261
- data = request.json
262
- image_url = data.get('imageUrl', '')
263
- print(f"\n📸 Received request for image analysis...")
264
-
265
- try:
266
- # Load image (handling both base64 Data URLs and HTTP URLs)
267
- if image_url.startswith('data:image/'):
268
- pattern = re.compile(r'^data:image/\w+;base64,(.*)$')
269
- match = pattern.match(image_url)
270
- if not match:
271
- raise ValueError("Invalid data URL format")
272
- img_data = base64.b64decode(match.group(1))
273
- img = Image.open(BytesIO(img_data))
274
- else:
275
- response = requests.get(image_url, timeout=10)
276
- img = Image.open(BytesIO(response.content))
277
-
278
- img_width, img_height = img.size
279
- print(f"👁️ Image size: {img_width}x{img_height}. Scanning for text blocks...")
280
-
281
- # Get OCR data (bounding box coordinates)
282
- ocr_data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
283
- extracted_text = pytesseract.image_to_string(img).strip()
284
-
285
- # Group words by block and line number to reconstruct coherent lines
286
- lines = {}
287
- n_boxes = len(ocr_data['text'])
288
- for i in range(n_boxes):
289
- text = ocr_data['text'][i].strip()
290
- if not text:
291
- continue
292
-
293
- block_num = ocr_data['block_num'][i]
294
- line_num = ocr_data['line_num'][i]
295
- key = (block_num, line_num)
296
-
297
- left = ocr_data['left'][i]
298
- top = ocr_data['top'][i]
299
- width = ocr_data['width'][i]
300
- height = ocr_data['height'][i]
301
-
302
- if key not in lines:
303
- lines[key] = {
304
- 'words': [],
305
- 'left': left,
306
- 'top': top,
307
- 'right': left + width,
308
- 'bottom': top + height
309
- }
310
-
311
- lines[key]['words'].append(text)
312
- lines[key]['left'] = min(lines[key]['left'], left)
313
- lines[key]['top'] = min(lines[key]['top'], top)
314
- lines[key]['right'] = max(lines[key]['right'], left + width)
315
- lines[key]['bottom'] = max(lines[key]['bottom'], top + height)
316
-
317
- dark_patterns = []
318
- pattern_id = 1
319
-
320
- for key, info in lines.items():
321
- line_text = " ".join(info['words']).strip()
322
- if len(line_text) < 3:
323
- continue
324
-
325
- # Predict pattern class
326
- prediction = model.predict([line_text])[0]
327
- if prediction != "Not Dark Pattern":
328
- probs = model.predict_proba([line_text])[0]
329
- classes = model.classes_
330
- pred_idx = list(classes).index(prediction)
331
- confidence_score = round(probs[pred_idx] * 100)
332
-
333
- severity = get_severity(prediction)
334
- violation, recommendation = get_compliance_metadata(prediction, line_text)
335
-
336
- # Convert coords to percentages relative to image size
337
- left_pct = round((info['left'] / img_width) * 100, 2)
338
- top_pct = round((info['top'] / img_height) * 100, 2)
339
- width_pct = round(((info['right'] - info['left']) / img_width) * 100, 2)
340
- height_pct = round(((info['bottom'] - info['top']) / img_height) * 100, 2)
341
-
342
- dark_patterns.append({
343
- "id": str(pattern_id),
344
- "type": prediction,
345
- "severity": severity,
346
- "description": f"Deceptive copywriting matching {prediction} pattern.",
347
- "confidence": confidence_score,
348
- "location": {
349
- "x": left_pct,
350
- "y": top_pct,
351
- "width": width_pct,
352
- "height": height_pct
353
- },
354
- "cfpbViolation": violation,
355
- "recommendation": recommendation
356
- })
357
- pattern_id += 1
358
-
359
- # Calculate trust score & compliance report
360
- if not dark_patterns:
361
- overall_score = 98
362
- risk_level = "low"
363
- compliance_report = {
364
- "cfpbAlignment": 98,
365
- "issues": [],
366
- "recommendations": []
367
- }
368
- else:
369
- deductions = {
370
- "critical": 25,
371
- "high": 15,
372
- "medium": 10,
373
- "low": 5
374
- }
375
- score_deduction = sum(deductions.get(p["severity"], 10) for p in dark_patterns)
376
- overall_score = max(5, 100 - score_deduction)
377
-
378
- if overall_score >= 80:
379
- risk_level = "low"
380
- elif overall_score >= 60:
381
- risk_level = "medium"
382
- elif overall_score >= 45:
383
- risk_level = "high"
384
- else:
385
- risk_level = "critical"
386
-
387
- issues = list(dict.fromkeys([p["cfpbViolation"] for p in dark_patterns]))
388
- recommendations = list(dict.fromkeys([p["recommendation"] for p in dark_patterns]))
389
-
390
- compliance_report = {
391
- "cfpbAlignment": overall_score,
392
- "issues": issues,
393
- "recommendations": recommendations
394
- }
395
-
396
- return jsonify({
397
- "imageUrl": image_url,
398
- "extractedText": extracted_text or "No text detected in screenshot.",
399
- "overallScore": overall_score,
400
- "riskLevel": risk_level,
401
- "darkPatterns": dark_patterns,
402
- "complianceReport": compliance_report,
403
- "timestamp": datetime.datetime.now().isoformat()
404
- })
405
-
406
- except Exception as e:
407
- print(f"❌ Analysis failed: {e}")
408
- return jsonify({"error": f"Failed to process image: {str(e)}"}), 500
409
-
410
- @app.route('/api/dataset', methods=['GET'])
411
- def get_dataset():
412
- query = request.args.get('q', '').strip()
413
- category = request.args.get('category', '').strip()
414
- limit = int(request.args.get('limit', 50))
415
- offset = int(request.args.get('offset', 0))
416
-
417
- try:
418
- filtered_df = df
419
- if query:
420
- filtered_df = filtered_df[filtered_df['text'].str.contains(query, case=False, na=False)]
421
- if category:
422
- filtered_df = filtered_df[filtered_df['Pattern Category'].str.lower() == category.lower()]
423
-
424
- total = len(filtered_df)
425
- sliced_df = filtered_df.iloc[offset:offset+limit]
426
-
427
- records = sliced_df.to_dict(orient='records')
428
-
429
- # Get category counts for stats
430
- counts = df['Pattern Category'].value_counts().to_dict()
431
-
432
- return jsonify({
433
- "status": "success",
434
- "total": total,
435
- "limit": limit,
436
- "offset": offset,
437
- "records": records,
438
- "categoryCounts": counts
439
- })
440
- except Exception as e:
441
- return jsonify({"status": "error", "message": str(e)}), 500
442
-
443
- @app.route('/api/stock/<symbol>', methods=['GET'])
444
- def get_stock_data(symbol):
445
- try:
446
- url = f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol.upper()}?range=1d&interval=5m"
447
- headers = {
448
- 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
449
- }
450
- response = requests.get(url, headers=headers, timeout=10)
451
-
452
- # If Yahoo Finance rate-limits (429) or fails, fallback to generating simulated stock metrics deterministically
453
- if response.status_code != 200:
454
- print(f"⚠️ Yahoo Finance API returned status {response.status_code} for {symbol}. Generating simulated fallback.")
455
- import random
456
- hash_val = sum(ord(c) for c in symbol.upper())
457
- base_price = 50.0 + (hash_val % 450)
458
-
459
- history = []
460
- price = base_price
461
- for i in range(20):
462
- price = price * (1 + (random.random() * 0.04 - 0.02))
463
- history.append({
464
- "time": f"T-{20-i}m",
465
- "price": round(price, 2)
466
- })
467
-
468
- current_price = price
469
- price_change = price * 0.015
470
- price_change_pct = 1.5
471
-
472
- distress_info = get_distress_risk(symbol)
473
- news_info = get_stock_news(symbol)
474
-
475
- return jsonify({
476
- "status": "success",
477
- "symbol": symbol.upper(),
478
- "price": round(current_price, 2),
479
- "change": round(price_change, 2),
480
- "changePercent": round(price_change_pct, 2),
481
- "history": history,
482
- "distress": distress_info,
483
- "news": news_info,
484
- "simulated": True
485
- })
486
-
487
- data = response.json()
488
- if not data.get('chart') or not data['chart'].get('result'):
489
- return jsonify({"status": "error", "message": "Invalid stock symbol or no data available."}), 404
490
-
491
- result = data['chart']['result'][0]
492
- meta = result.get('meta', {})
493
- current_price = meta.get('regularMarketPrice', 0)
494
- previous_close = meta.get('chartPreviousClose', current_price)
495
- price_change = current_price - previous_close
496
- price_change_pct = (price_change / previous_close) * 100 if previous_close else 0
497
-
498
- timestamps = result.get('timestamp', [])
499
- quotes = result.get('indicators', {}).get('quote', [{}])[0].get('close', [])
500
-
501
- history = []
502
- for t, val in zip(timestamps, quotes):
503
- if val is not None:
504
- time_str = datetime.datetime.fromtimestamp(t).strftime('%H:%M')
505
- history.append({
506
- "time": time_str,
507
- "price": round(val, 2)
508
- })
509
-
510
- distress_info = get_distress_risk(symbol)
511
- news_info = get_stock_news(symbol)
512
-
513
- return jsonify({
514
- "status": "success",
515
- "symbol": symbol.upper(),
516
- "price": round(current_price, 2),
517
- "change": round(price_change, 2),
518
- "changePercent": round(price_change_pct, 2),
519
- "history": history,
520
- "distress": distress_info,
521
- "news": news_info
522
- })
523
- except Exception as e:
524
- # Fallback if any internal python exception occurs
525
- print(f"⚠️ Exception in get_stock_data for {symbol}: {e}. Generating simulated fallback.")
526
- import random
527
- hash_val = sum(ord(c) for c in symbol.upper())
528
- base_price = 50.0 + (hash_val % 450)
529
- history = []
530
- price = base_price
531
- for i in range(20):
532
- price = price * (1 + (random.random() * 0.04 - 0.02))
533
- history.append({
534
- "time": f"T-{20-i}m",
535
- "price": round(price, 2)
536
- })
537
- return jsonify({
538
- "status": "success",
539
- "symbol": symbol.upper(),
540
- "price": round(price, 2),
541
- "change": round(price * 0.015, 2),
542
- "changePercent": 1.5,
543
- "history": history,
544
- "distress": get_distress_risk(symbol),
545
- "news": get_stock_news(symbol),
546
- "simulated": True
547
- })
548
-
549
- def format_volume(val):
550
- try:
551
- val_float = float(val)
552
- if val_float >= 1e9:
553
- return f"${val_float / 1e9:.2f} B"
554
- elif val_float >= 1e6:
555
- return f"${val_float / 1e6:.2f} M"
556
- else:
557
- return f"${val_float:,.0f}"
558
- except Exception:
559
- return "$0.00"
560
-
561
- @app.route('/api/market/assets', methods=['GET'])
562
- def get_market_assets():
563
- print("📈 Fetching live market assets statistics...")
564
- assets_def = [
565
- {"symbol": "BTC-USD", "name": "Bitcoin", "type": "crypto", "basePrice": 67645.0, "baseChange": 1.4},
566
- {"symbol": "ETH-USD", "name": "Ethereum", "type": "crypto", "basePrice": 3450.0, "baseChange": -0.8},
567
- {"symbol": "SOL-USD", "name": "Solana", "type": "crypto", "basePrice": 165.20, "baseChange": 4.2},
568
- {"symbol": "DOGE-USD", "name": "Dogecoin", "type": "crypto", "basePrice": 0.142, "baseChange": -2.1},
569
- {"symbol": "NVDA", "name": "NVIDIA Corp.", "type": "stock", "basePrice": 120.50, "baseChange": 3.8},
570
- {"symbol": "AAPL", "name": "Apple Inc.", "type": "stock", "basePrice": 175.20, "baseChange": -0.4},
571
- {"symbol": "TSLA", "name": "Tesla Inc.", "type": "stock", "basePrice": 185.0, "baseChange": 0.5},
572
- {"symbol": "COIN", "name": "Coinbase Global", "type": "stock", "basePrice": 220.40, "baseChange": -1.9}
573
- ]
574
-
575
- headers = {
576
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
577
- }
578
-
579
- output_assets = []
580
-
581
- for asset in assets_def:
582
- symbol = asset["symbol"]
583
- base_price = asset["basePrice"]
584
- base_change = asset["baseChange"]
585
-
586
- price = base_price
587
- change_pct = base_change
588
- high_24h = base_price * 1.02
589
- low_24h = base_price * 0.98
590
- volume_val = 0.0
591
- sparkline = []
592
- is_simulated = True
593
-
594
- # 1. Try Yahoo Finance Chart API
595
- try:
596
- url = f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol}?range=1d&interval=15m"
597
- res = requests.get(url, headers=headers, timeout=4)
598
- if res.status_code == 200:
599
- data = res.json()
600
- result = data['chart']['result'][0]
601
- meta = result.get('meta', {})
602
-
603
- current_price = meta.get('regularMarketPrice')
604
- previous_close = meta.get('chartPreviousClose')
605
-
606
- if current_price is not None and current_price > 0:
607
- price = current_price
608
- if previous_close is not None and previous_close > 0:
609
- change_pct = ((current_price - previous_close) / previous_close) * 100
610
-
611
- high_24h = meta.get('regularMarketDayHigh', price * 1.02)
612
- low_24h = meta.get('regularMarketDayLow', price * 0.98)
613
-
614
- vol = meta.get('regularMarketVolume', 0)
615
- if asset["type"] == "stock":
616
- # Stock volume is shares; multiply by price to get USD volume
617
- volume_val = vol * price
618
- else:
619
- volume_val = vol
620
-
621
- # Extract historical quotes for sparkline
622
- quotes = result.get('indicators', {}).get('quote', [{}])[0].get('close', [])
623
- clean_quotes = [round(val, 2 if price >= 1.0 else 4) for val in quotes if val is not None]
624
-
625
- if len(clean_quotes) >= 10:
626
- step = len(clean_quotes) / 10.0
627
- sparkline = [clean_quotes[int(i * step)] for i in range(10)]
628
- sparkline[-1] = clean_quotes[-1]
629
- elif len(clean_quotes) > 0:
630
- sparkline = clean_quotes
631
-
632
- is_simulated = False
633
- except Exception as e:
634
- print(f"⚠️ Yahoo Finance failed for {symbol}: {e}")
635
-
636
- # 2. Try Binance API as fallback for Crypto
637
- if is_simulated and asset["type"] == "crypto":
638
- try:
639
- binance_sym = symbol.replace("-USD", "USDT")
640
- url = f"https://api.binance.com/api/v3/ticker/24hr?symbol={binance_sym}"
641
- res = requests.get(url, timeout=3)
642
- if res.status_code == 200:
643
- b_data = res.json()
644
- price = float(b_data["lastPrice"])
645
- change_pct = float(b_data["priceChangePercent"])
646
- high_24h = float(b_data["highPrice"])
647
- low_24h = float(b_data["lowPrice"])
648
- volume_val = float(b_data["quoteVolume"]) # quoteVolume is USDT volume
649
- is_simulated = False
650
- print(f"✅ Fallback to Binance successful for {symbol}: Price = {price}")
651
- except Exception as e:
652
- print(f"⚠️ Binance fallback failed for {symbol}: {e}")
653
-
654
- # 3. Fallback to Simulated Quote if all APIs failed
655
- import random
656
- if is_simulated:
657
- price = price * (1 + (random.random() * 0.002 - 0.001))
658
- high_24h = price * 1.02
659
- low_24h = price * 0.98
660
- # Use deterministic base volume
661
- if symbol == "BTC-USD": volume_val = 28450210000
662
- elif symbol == "ETH-USD": volume_val = 14120450000
663
- elif symbol == "SOL-USD": volume_val = 3510800000
664
- elif symbol == "DOGE-USD": volume_val = 1240150000
665
- elif symbol == "NVDA": volume_val = 18540900000
666
- elif symbol == "AAPL": volume_val = 8450600000
667
- elif symbol == "TSLA": volume_val = 9210300000
668
- else: volume_val = 2150400000
669
-
670
- # Ensure we have a valid 10-point sparkline
671
- if not sparkline or len(sparkline) < 10:
672
- sparkline = []
673
- hist_price = price * (1 - (change_pct / 100))
674
- for i in range(10):
675
- jitter = (random.random() * 0.02 - 0.01) * hist_price
676
- sparkline.append(round(hist_price + (i * (price - hist_price)/9) + jitter, 2 if price >= 1.0 else 4))
677
-
678
- # Format volume string
679
- if volume_val > 0:
680
- volume_str = format_volume(volume_val)
681
- else:
682
- if symbol == "BTC-USD": volume_str = "$28,450,210,000"
683
- elif symbol == "ETH-USD": volume_str = "$14,120,450,000"
684
- elif symbol == "SOL-USD": volume_str = "$3,510,800,000"
685
- elif symbol == "DOGE-USD": volume_str = "$1,240,150,000"
686
- elif symbol == "NVDA": volume_str = "$18,540,900,000"
687
- elif symbol == "AAPL": volume_str = "$8,450,600,000"
688
- elif symbol == "TSLA": volume_str = "$9,210,300,000"
689
- else: volume_str = "$2,150,400,000"
690
-
691
- # Calculate dynamic setups
692
- price_str = f"{price:,.2f}" if price >= 1.0 else f"{price:,.4f}"
693
- if change_pct >= 2.0:
694
- buy_p = random.randint(65, 80)
695
- sell_p = 100 - buy_p
696
- momentum = "Bullish"
697
- signal = "Strong Buy"
698
- analysis = f"{asset['name']} is experiencing a powerful breakout, surging {change_pct:.2f}% to ${price_str}. High volume buying pressure ({buy_p}%) has overwhelmed key overhead resistance. Relative Strength Index (RSI) is expanding rapidly, confirming strong bullish momentum."
699
- elif change_pct >= 0.2:
700
- buy_p = random.randint(52, 64)
701
- sell_p = 100 - buy_p
702
- momentum = "Bullish"
703
- signal = "Buy"
704
- analysis = f"{asset['name']} maintains a positive structure, trading up {change_pct:.2f}% at ${price_str}. The asset is holding support above the 50-day moving average, with spot order book flow showing steady bid accumulation."
705
- elif change_pct <= -2.0:
706
- buy_p = random.randint(20, 38)
707
- sell_p = 100 - buy_p
708
- momentum = "Bearish"
709
- signal = "Sell"
710
- analysis = f"{asset['name']} has broken key support to the downside, dropping {change_pct:.2f}% to ${price_str}. Sellers are in full control with {sell_p}% volume pressure. Momentum indicators are oversold, but advise waiting for a bottom structure to form."
711
- elif change_pct <= -0.2:
712
- buy_p = random.randint(39, 47)
713
- sell_p = 100 - buy_p
714
- momentum = "Bearish"
715
- signal = "Sell"
716
- analysis = f"{asset['name']} is under minor distribution, trading down {change_pct:.2f}% at ${price_str}. Selling pressure is slightly elevated, suggesting continuation of a short-term consolidation pattern before buyers re-engage."
717
- else:
718
- buy_p = random.randint(48, 51)
719
- sell_p = 100 - buy_p
720
- momentum = "Neutral"
721
- signal = "Hold"
722
- analysis = f"{asset['name']} is moving in a tight sideways range, currently priced at ${price_str} ({change_pct:+.2f}%). Spot volume is balanced, indicating a neutral tug-of-war between bulls and bears with no clear trend direction."
723
-
724
- # Assign warning metadata (theme based)
725
- if symbol == "BTC-USD":
726
- risk_lvl = "Low"
727
- warnings = ["Urgency FOMO banners active on major brokers", "Stealth spread markups active on buy trades"]
728
- elif symbol == "ETH-USD":
729
- risk_lvl = "Low"
730
- warnings = ["Deceptive staking yield advertisements (hidden locking fees)"]
731
- elif symbol == "SOL-USD":
732
- risk_lvl = "Low"
733
- warnings = ["High transaction failure gas fee warnings omitted by UI"]
734
- elif symbol == "DOGE-USD":
735
- risk_lvl = "Medium"
736
- warnings = ["Pressure pop-ups ('DOGE is spiking! Buy before it runs!') active"]
737
- elif symbol == "NVDA":
738
- risk_lvl = "Low"
739
- warnings = ["Visual misdirection: hiding index correlation parameters"]
740
- elif symbol == "AAPL":
741
- risk_lvl = "Low"
742
- warnings = ["Sneaked add-on fees (recurring equity analyst newsletter pre-checked)"]
743
- elif symbol == "TSLA":
744
- risk_lvl = "Low"
745
- warnings = ["Deceptive countdown timers on pricing locked deals"]
746
- else: # COIN
747
- risk_lvl = "High"
748
- warnings = ["Deceptive rating: suppressing distress warning under low risk badge", "Cart sneaking: $4.99 options analytics pre-checked"]
749
-
750
- output_assets.append({
751
- "symbol": symbol,
752
- "name": asset["name"],
753
- "type": asset["type"],
754
- "price": round(price, 2 if price >= 1.0 else 4),
755
- "change24h": round(change_pct, 2),
756
- "volume24h": volume_str,
757
- "high24h": round(high_24h, 2 if price >= 1.0 else 4),
758
- "low24h": round(low_24h, 2 if price >= 1.0 else 4),
759
- "sparkline": sparkline,
760
- "buySellPattern": {
761
- "buyPressure": buy_p,
762
- "sellPressure": sell_p,
763
- "momentum": momentum,
764
- "signal": signal,
765
- "analysis": analysis
766
- },
767
- "fintechWarnings": {
768
- "riskLevel": risk_lvl,
769
- "activePatterns": warnings
770
- }
771
- })
772
-
773
- return jsonify({
774
- "status": "success",
775
- "assets": output_assets,
776
- "timestamp": datetime.datetime.now().isoformat()
777
- })
778
-
779
- @app.route('/api/analyze-options', methods=['POST', 'OPTIONS'])
780
- def analyze_options():
781
- if request.method == 'OPTIONS':
782
- return jsonify({}), 200
783
-
784
- data = request.json
785
- image_url = data.get('imageUrl', '')
786
- print(f"\n📊 Received request for options analysis...")
787
-
788
- extracted_text = ""
789
- is_options_screenshot = False
790
-
791
- try:
792
- if image_url:
793
- # Decode base64
794
- if image_url.startswith('data:image/'):
795
- pattern = re.compile(r'^data:image/\w+;base64,(.*)$')
796
- match = pattern.match(image_url)
797
- if not match:
798
- raise ValueError("Invalid data URL format")
799
- img_data = base64.b64decode(match.group(1))
800
- img = Image.open(BytesIO(img_data))
801
- else:
802
- response = requests.get(image_url, timeout=10)
803
- img = Image.open(BytesIO(response.content))
804
-
805
- extracted_text = pytesseract.image_to_string(img).strip()
806
-
807
- # Simple check if this is an options chain screenshot
808
- lower_text = extracted_text.lower()
809
- keywords = ["deribit", "option", "strike", "call", "put", "iv bid", "iv ask", "delta", "bid-ask"]
810
- keyword_matches = sum(1 for kw in keywords if kw in lower_text)
811
- if keyword_matches >= 2 or any(str(strike) in lower_text for strike in [65000, 66000, 67000, 68000, 69000, 70000]):
812
- is_options_screenshot = True
813
-
814
- except Exception as e:
815
- print(f"⚠️ OCR extraction failed: {e}. Falling back to default options analysis.")
816
- is_options_screenshot = False
817
-
818
- # Default/simulated option chain values based on BTC at $67,645.00
819
- # Perfect copy of Deribit screenshot data
820
- spot_price = 67645.00
821
- expiry_date = "03 Jun 2026"
822
- time_to_expiry_hours = 16.7
823
-
824
- # We will generate a structured grid for strikes: 65,000 to 75,000
825
- strikes_data = [
826
- {"strike": 65000, "callSize": 2.0, "callBid": 0.0375, "callAsk": 0.0460, "callIvBid": 69.0, "callIvAsk": 122.2, "putSize": 10.8, "putBid": 0.0011, "putAsk": 0.0013, "putIvBid": 66.3, "putIvAsk": 69.2},
827
- {"strike": 66000, "callSize": 2.2, "callBid": 0.0235, "callAsk": 0.0315, "callIvBid": 62.5, "callIvAsk": 96.3, "putSize": 25.2, "putBid": 0.0024, "putAsk": 0.0028, "putIvBid": 59.8, "putIvAsk": 63.3},
828
- {"strike": 67000, "callSize": 0.1, "callBid": 0.0145, "callAsk": 0.0155, "callIvBid": 51.5, "callIvAsk": 57.7, "putSize": 79.6, "putBid": 0.0050, "putAsk": 0.0060, "putIvBid": 51.6, "putIvAsk": 57.9},
829
- {"strike": 68000, "callSize": 13.2, "callBid": 0.0060, "callAsk": 0.0070, "callIvBid": 47.8, "callIvAsk": 53.7, "putSize": 0.4, "putBid": 0.0115, "putAsk": 0.0120, "putIvBid": 49.3, "putIvAsk": 52.3},
830
- {"strike": 69000, "callSize": 0.4, "callBid": 0.0018, "callAsk": 0.0021, "callIvBid": 46.6, "callIvAsk": 49.3, "putSize": 5.5, "putBid": 0.0150, "putAsk": 0.0180, "putIvBid": 39.4, "putIvAsk": 59.8},
831
- {"strike": 70000, "callSize": 3.5, "callBid": 0.0009, "callAsk": 0.0011, "callIvBid": 46.8, "callIvAsk": 49.4, "putSize": 0.8, "putBid": 0.0270, "putAsk": 0.0300, "putIvBid": 31.5, "putIvAsk": 64.6},
832
- {"strike": 71000, "callSize": 2.7, "callBid": 0.0002, "callAsk": 0.0003, "callIvBid": 55.0, "callIvAsk": 58.7, "putSize": 0.4, "putBid": 0.0485, "putAsk": 0.0515, "putIvBid": 50.0, "putIvAsk": 87.3},
833
- {"strike": 72000, "callSize": 10.2, "callBid": 0.0001, "callAsk": 0.0002, "callIvBid": 55.9, "callIvAsk": 61.6, "putSize": 0.7, "putBid": 0.0630, "putAsk": 0.0660, "putIvBid": 50.0, "putIvAsk": 101.0}
834
- ]
835
-
836
- # Calculate Put-Call Ratio (PCR) and ATM Skew
837
- # ATM strike is 68000 (closest to spot $67,645.00)
838
- atm_strike = 68000
839
- atm_opt = next((x for x in strikes_data if x["strike"] == atm_strike), strikes_data[3])
840
-
841
- atm_call_iv = (atm_opt["callIvBid"] + atm_opt["callIvAsk"]) / 2
842
- atm_put_iv = (atm_opt["putIvBid"] + atm_opt["putIvAsk"]) / 2
843
- iv_skew = round(atm_put_iv - atm_call_iv, 2) # positive skew means Puts are more expensive than Calls (bearish fear)
844
-
845
- total_call_size = sum(x["callSize"] for x in strikes_data)
846
- total_put_size = sum(x["putSize"] for x in strikes_data)
847
- pcr_ratio = round(total_put_size / total_call_size, 2) if total_call_size > 0 else 1.0
848
-
849
- # Determine "When is a good time to buy and sell options"
850
- signals = []
851
- recommended_action = "Hold"
852
- action_explanation = ""
853
-
854
- if iv_skew > 1.5:
855
- signals.append(f"Volatility Skew is highly positive (+{iv_skew}%), showing put option premiums are heavily inflated due to downside hedging demand (market fear).")
856
- if pcr_ratio > 1.1:
857
- recommended_action = "Sell Put Credit Spreads / Buy Calls"
858
- action_explanation = "Fear is peaking (high IV skew + high Put-Call Ratio). This is historically a good time to SELL puts to collect high option premiums, or BUY call options at a discount as the underlying asset consolidates near support."
859
- else:
860
- recommended_action = "Sell Put Options (Income Harvest)"
861
- action_explanation = "Put premiums are elevated. Sell put options or put spreads to harvest high volatility premium."
862
- elif iv_skew < -1.5:
863
- signals.append(f"Volatility Skew is negative ({iv_skew}%), showing call option premiums are inflated due to upside FOMO buying.")
864
- if pcr_ratio < 0.8:
865
- recommended_action = "Buy Put Options (Hedge) / Sell Calls"
866
- action_explanation = "Market euphoria is high. Call premiums are overpriced and Put options are cheap. It is a good time to BUY puts as a low-cost downside hedge or SELL covered calls to lock in yield."
867
- else:
868
- recommended_action = "Buy Puts / Sell Call Spreads"
869
- action_explanation = "Call premiums are inflated. Buy cheap puts to position for a reversion."
870
- else:
871
- signals.append(f"Volatility Skew is neutral ({iv_skew}%), indicating balanced demand between call and put options.")
872
- if pcr_ratio > 1.3:
873
- recommended_action = "Buy Calls (Contrarian)"
874
- action_explanation = "Put-Call ratio is heavily skewed to puts, indicating oversold sentiment. A good time to buy calls for a relief rally."
875
- elif pcr_ratio < 0.6:
876
- recommended_action = "Buy Puts (Contrarian)"
877
- action_explanation = "Put-Call ratio is heavily skewed to calls, indicating overbought hype. A good time to buy puts for a cooling off period."
878
- else:
879
- recommended_action = "Hold / Neutral"
880
- action_explanation = "Volatility and volume distributions are balanced. Standard market conditions. Avoid opening large directional options exposure; look for range-bound credit strategies."
881
-
882
- # Identify dark patterns/compliance issues in the options layout
883
- compliance_issues = []
884
- compliance_recommendations = []
885
-
886
- # 1. Hidden option markups (wide spreads)
887
- wide_spreads = False
888
- for x in strikes_data:
889
- call_mid = (x["callBid"] + x["callAsk"]) / 2
890
- call_spread_pct = ((x["callAsk"] - x["callBid"]) / call_mid) * 100 if call_mid > 0 else 0
891
- if call_spread_pct > 15:
892
- wide_spreads = True
893
- break
894
-
895
- if wide_spreads or is_options_screenshot:
896
- compliance_issues.append("Stealth Option Markups: Bid-ask spreads on out-of-the-money options exceed 15% of the option's value, acting as a hidden fee (Sneaking).")
897
- compliance_recommendations.append("Disclose the bid-ask spread percentages in real-time next to the order button so retail traders understand the slippage fee.")
898
-
899
- # 2. Urgency
900
- compliance_issues.append("Urgency Expiry Alerts: Countdown banner 'BTC-3JUN26 contracts expire in 16 hours! Lock in premium now!' creates artificial pressure (Urgency).")
901
- compliance_recommendations.append("Remove high-pressure countdown phrases like 'Lock in premium now' and replace with a standard, non-colored expiry date label.")
902
-
903
- # 3. Complexity barrier
904
- compliance_issues.append("Obstruction of Key Information: Displaying Greek metrics (Delta, Gamma, Vega, Theta) and IV levels without tooltips or explanations confuses retail users into making risky leverage trades (Obstruction).")
905
- compliance_recommendations.append("Add interactive tooltips explaining what Delta, IV, and Bid/Ask spreads mean, along with a warning of the high risk of options trading.")
906
-
907
- overall_score = 65
908
- risk_level = "medium"
909
-
910
- return jsonify({
911
- "status": "success",
912
- "asset": "BTC",
913
- "spotPrice": spot_price,
914
- "expiryDate": expiry_date,
915
- "timeToExpiryHours": time_to_expiry_hours,
916
- "strikes": strikes_data,
917
- "ivSkew": iv_skew,
918
- "putCallRatio": pcr_ratio,
919
- "signal": {
920
- "recommendation": recommended_action,
921
- "explanation": action_explanation,
922
- "indicators": signals
923
- },
924
- "compliance": {
925
- "score": overall_score,
926
- "riskLevel": risk_level,
927
- "issues": compliance_issues,
928
- "recommendations": compliance_recommendations
929
- },
930
- "extractedText": extracted_text or "Simulated options chain screen text parsed."
931
- })
932
-
933
- if os.path.exists(dist_dir):
934
- @app.route('/', defaults={'path': ''})
935
- @app.route('/<path:path>')
936
- def serve(path):
937
- if path != "" and os.path.exists(os.path.join(app.static_folder, path)):
938
- return app.send_static_file(path)
939
- else:
940
- return app.send_static_file('index.html')
941
-
942
- if __name__ == '__main__':
943
- app.run(host='0.0.0.0', port=8000, debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, request, jsonify
2
+ from flask_cors import CORS
3
+ import pandas as pd
4
+ from sklearn.feature_extraction.text import TfidfVectorizer
5
+ from sklearn.linear_model import LogisticRegression
6
+ from sklearn.pipeline import make_pipeline
7
+ from sklearn.model_selection import train_test_split
8
+ from sklearn.metrics import classification_report, confusion_matrix, accuracy_score
9
+ import pytesseract
10
+ from PIL import Image
11
+ import shutil
12
+ import os
13
+ import sys
14
+
15
+ # Windows local dev: emoji log lines crash on cp1252 consoles
16
+ if hasattr(sys.stdout, 'reconfigure'):
17
+ try:
18
+ sys.stdout.reconfigure(encoding='utf-8')
19
+ sys.stderr.reconfigure(encoding='utf-8')
20
+ except Exception:
21
+ pass
22
+
23
+ # Windows local dev: fall back to the default install path when tesseract isn't on PATH
24
+ if shutil.which('tesseract') is None:
25
+ _win_tesseract = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
26
+ if os.name == 'nt' and os.path.exists(_win_tesseract):
27
+ pytesseract.pytesseract.tesseract_cmd = _win_tesseract
28
+ import requests
29
+ from io import BytesIO
30
+ import base64
31
+ import re
32
+ import datetime
33
+ import os
34
+
35
+ script_dir = os.path.dirname(os.path.abspath(__file__))
36
+ dist_dir = os.path.abspath(os.path.join(script_dir, '../dist'))
37
+
38
+ if os.path.exists(dist_dir):
39
+ print(f"📦 Serving static files from production build: {dist_dir}")
40
+ app = Flask(__name__, static_folder=dist_dir, static_url_path='/')
41
+ else:
42
+ print("🧪 Running in development API mode (no dist folder found)")
43
+ app = Flask(__name__)
44
+
45
+ # Enable CORS for all API paths
46
+ CORS(app, resources={r"/api/*": {"origins": "*"}})
47
+
48
+ print("🧠 Loading NLP model from dataset.csv...")
49
+ model_metrics = None
50
+ try:
51
+ script_dir = os.path.dirname(os.path.abspath(__file__))
52
+ dataset_path = os.path.join(script_dir, 'dataset.csv')
53
+ df = pd.read_csv(dataset_path)
54
+ df = df.dropna(subset=['text', 'Pattern Category'])
55
+
56
+ def build_model():
57
+ return make_pipeline(
58
+ TfidfVectorizer(ngram_range=(1, 3), sublinear_tf=True, min_df=2, max_features=50_000),
59
+ LogisticRegression(C=5.0, class_weight='balanced', max_iter=2000)
60
+ )
61
+
62
+ # Held-out evaluation on a stratified 80/20 split so the UI can show honest metrics
63
+ X_train, X_test, y_train, y_test = train_test_split(
64
+ df['text'], df['Pattern Category'],
65
+ test_size=0.20, random_state=42, stratify=df['Pattern Category']
66
+ )
67
+ eval_model = build_model()
68
+ eval_model.fit(X_train, y_train)
69
+ y_pred = eval_model.predict(X_test)
70
+
71
+ report = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
72
+ cm_labels = sorted(df['Pattern Category'].unique())
73
+ cm = confusion_matrix(y_test, y_pred, labels=cm_labels)
74
+
75
+ model_metrics = {
76
+ "modelName": "TF-IDF (1-3 grams, sublinear) + Logistic Regression (C=5, balanced)",
77
+ "datasetSize": int(len(df)),
78
+ "numClasses": int(df['Pattern Category'].nunique()),
79
+ "testSize": int(len(y_test)),
80
+ "accuracy": round(accuracy_score(y_test, y_pred), 4),
81
+ "macroF1": round(report['macro avg']['f1-score'], 4),
82
+ "weightedF1": round(report['weighted avg']['f1-score'], 4),
83
+ "perClass": {
84
+ cls: {
85
+ "precision": round(stats['precision'], 3),
86
+ "recall": round(stats['recall'], 3),
87
+ "f1": round(stats['f1-score'], 3),
88
+ "support": int(stats['support'])
89
+ }
90
+ for cls, stats in report.items()
91
+ if cls not in ('accuracy', 'macro avg', 'weighted avg')
92
+ },
93
+ "classDistribution": df['Pattern Category'].value_counts().to_dict(),
94
+ "confusionMatrix": {"labels": cm_labels, "matrix": cm.tolist()},
95
+ }
96
+ print(f"📊 Held-out evaluation: accuracy={model_metrics['accuracy']}, macro-F1={model_metrics['macroF1']}")
97
+
98
+ # Production model: refit on the full dataset
99
+ model = build_model()
100
+ model.fit(df['text'], df['Pattern Category'])
101
+ print("✅ AI Model ready and trained!")
102
+ except Exception as e:
103
+ print(f"❌ Error loading dataset: {e}")
104
+
105
+
106
+ def explain_prediction(text, predicted_class, top_n=5):
107
+ """Return the n-grams in `text` that contributed most to `predicted_class`.
108
+
109
+ For a linear model, the contribution of each feature is tfidf_value * coef.
110
+ """
111
+ try:
112
+ tfidf = model.named_steps['tfidfvectorizer']
113
+ logreg = model.named_steps['logisticregression']
114
+ class_idx = list(logreg.classes_).index(predicted_class)
115
+ vec = tfidf.transform([text])
116
+ feature_names = tfidf.get_feature_names_out()
117
+ contributions = []
118
+ for col, value in zip(vec.indices, vec.data):
119
+ contributions.append((feature_names[col], float(value * logreg.coef_[class_idx][col])))
120
+ contributions.sort(key=lambda x: x[1], reverse=True)
121
+ return [
122
+ {"phrase": phrase, "weight": round(weight, 4)}
123
+ for phrase, weight in contributions[:top_n] if weight > 0
124
+ ]
125
+ except Exception:
126
+ return []
127
+
128
+ print("📉 Loading Financial Distress model from Financial Distress.csv...")
129
+ distress_model = None
130
+ try:
131
+ distress_path = os.path.join(script_dir, 'Financial Distress.csv')
132
+ distress_df = pd.read_csv(distress_path)
133
+
134
+ # Financial Distress target value <= -0.5 is distress (class 1), else healthy (class 0)
135
+ features_cols = [f'x{i}' for i in range(1, 84)]
136
+ distress_df = distress_df.dropna(subset=['Financial Distress'] + features_cols)
137
+
138
+ X_distress = distress_df[features_cols]
139
+ y_distress = (distress_df['Financial Distress'] <= -0.5).astype(int)
140
+
141
+ distress_model = LogisticRegression(max_iter=1000)
142
+ distress_model.fit(X_distress, y_distress)
143
+ print(" Financial Distress Model ready and trained!")
144
+ except Exception as e:
145
+ print(f"❌ Error loading Financial Distress dataset: {e}")
146
+
147
+ print("📰 Loading Reddit News from RedditNews.csv...")
148
+ news_list = []
149
+ try:
150
+ news_path = os.path.join(script_dir, 'RedditNews.csv')
151
+ news_df = pd.read_csv(news_path)
152
+ news_df = news_df.dropna(subset=['News'])
153
+ news_list = news_df.to_dict(orient='records')
154
+ print(f"✅ Loaded {len(news_list)} news headlines successfully!")
155
+ except Exception as e:
156
+ print(f"❌ Error loading RedditNews dataset: {e}")
157
+
158
+ def analyze_headline_sentiment(news_text):
159
+ pos_words = ["gain", "rise", "success", "profit", "win", "high", "positive", "growth", "launch", "heal", "benefit", "good", "strong", "advance", "recover", "save", "safe"]
160
+ neg_words = ["fail", "drop", "loss", "crash", "investigate", "lawsuit", "down", "recession", "decrease", "kill", "death", "protest", "strike", "bad", "weak", "decline", "default", "scandal", "abuse", "murder", "hurt", "risk"]
161
+
162
+ text_lower = news_text.lower()
163
+ pos_score = sum(1 for word in pos_words if word in text_lower)
164
+ neg_score = sum(1 for word in neg_words if word in text_lower)
165
+
166
+ if pos_score > neg_score:
167
+ return "positive"
168
+ elif neg_score > pos_score:
169
+ return "negative"
170
+ else:
171
+ return "neutral"
172
+
173
+ def get_stock_news(symbol):
174
+ symbol = symbol.upper()
175
+ keywords = {
176
+ "AAPL": ["apple", "iphone", "macbook", "ipad", "jobs", "tech"],
177
+ "NVDA": ["chip", "nvidia", "gpu", "ai", "intel", "amd", "tech"],
178
+ "TSLA": ["tesla", "elon", "musk", "electric", "battery", "car"],
179
+ "COIN": ["bitcoin", "crypto", "blockchain", "exchange", "coinbase", "sec"],
180
+ "MSFT": ["microsoft", "windows", "azure", "cloud", "tech", "gates"],
181
+ "GOOGL": ["google", "alphabet", "search", "youtube", "android", "tech"],
182
+ }
183
+
184
+ stock_kws = keywords.get(symbol, [symbol.lower(), "market", "economy", "finance", "stocks", "trade", "shares"])
185
+
186
+ matching = []
187
+ for item in news_list:
188
+ news_text = str(item['News'])
189
+ text_lower = news_text.lower()
190
+ if any(kw in text_lower for kw in stock_kws):
191
+ matching.append(item)
192
+ if len(matching) >= 100:
193
+ break
194
+
195
+ if len(matching) < 5:
196
+ general_kws = ["market", "economy", "finance", "stocks", "trade", "shares"]
197
+ for item in news_list:
198
+ news_text = str(item['News'])
199
+ text_lower = news_text.lower()
200
+ if any(kw in text_lower for kw in general_kws):
201
+ matching.append(item)
202
+ if len(matching) >= 100:
203
+ break
204
+
205
+ formatted_news = []
206
+ positive_count = 0
207
+ negative_count = 0
208
+
209
+ # We want a mix of headlines (e.g. 6 headlines)
210
+ selected_items = matching[:6]
211
+ if len(selected_items) < 6:
212
+ selected_items = news_list[:6]
213
+
214
+ for item in selected_items:
215
+ headline = str(item['News'])
216
+ date = str(item['Date'])
217
+ sentiment = analyze_headline_sentiment(headline)
218
+
219
+ if sentiment == "positive":
220
+ positive_count += 1
221
+ elif sentiment == "negative":
222
+ negative_count += 1
223
+
224
+ formatted_news.append({
225
+ "headline": headline,
226
+ "date": date,
227
+ "sentiment": sentiment
228
+ })
229
+
230
+ total_val = positive_count + negative_count
231
+ if total_val > 0:
232
+ sentiment_pct = round((positive_count / total_val) * 100)
233
+ else:
234
+ # A deterministic fallback sentiment based on symbol hash
235
+ hash_val = sum(ord(c) for c in symbol)
236
+ sentiment_pct = 40 + (hash_val % 30) # 40% to 70% positive
237
+
238
+ return {
239
+ "articles": formatted_news,
240
+ "sentimentPercent": sentiment_pct
241
+ }
242
+
243
+ def get_distress_risk(symbol):
244
+ if distress_model is None:
245
+ return {"riskLevel": "Low", "distressProbability": 15.0, "rawDistressScore": 0.05, "isDistressed": False}
246
+
247
+ symbol = symbol.upper()
248
+ try:
249
+ script_dir = os.path.dirname(os.path.abspath(__file__))
250
+ distress_path = os.path.join(script_dir, 'Financial Distress.csv')
251
+ distress_df = pd.read_csv(distress_path)
252
+
253
+ # Filter rows to select distressed vs healthy for demo consistency
254
+ distressed_rows = distress_df[distress_df['Financial Distress'] <= -0.5]
255
+ healthy_rows = distress_df[distress_df['Financial Distress'] > 0.5]
256
+
257
+ if len(distressed_rows) == 0 or len(healthy_rows) == 0:
258
+ return {"riskLevel": "Low", "distressProbability": 10.0, "rawDistressScore": 0.1, "isDistressed": False}
259
+
260
+ # Deterministic row selection based on symbol hash
261
+ hash_val = sum(ord(c) for c in symbol)
262
+
263
+ # Override specific symbols for demonstration purposes:
264
+ if symbol == 'COIN':
265
+ # Map COIN to a distressed row
266
+ row = distressed_rows.iloc[hash_val % len(distressed_rows)]
267
+ elif symbol in ['AAPL', 'NVDA', 'MSFT', 'GOOGL']:
268
+ # Map healthy tech to healthy row
269
+ row = healthy_rows.iloc[hash_val % len(healthy_rows)]
270
+ else:
271
+ # Map deterministically from entire dataset
272
+ row = distress_df.iloc[hash_val % len(distress_df)]
273
+
274
+ features_cols = [f'x{i}' for i in range(1, 84)]
275
+ features = row[features_cols].values.reshape(1, -1)
276
+
277
+ prob = distress_model.predict_proba(features)[0][1] # probability of class 1 (distress)
278
+ is_distressed = bool(distress_model.predict(features)[0] == 1)
279
+
280
+ # Define risk levels:
281
+ if prob > 0.6 or is_distressed:
282
+ risk_level = "High"
283
+ elif prob > 0.25:
284
+ risk_level = "Medium"
285
+ else:
286
+ risk_level = "Low"
287
+
288
+ raw_score = float(row['Financial Distress'])
289
+
290
+ return {
291
+ "riskLevel": risk_level,
292
+ "distressProbability": round(float(prob) * 100, 1),
293
+ "rawDistressScore": round(raw_score, 3),
294
+ "isDistressed": is_distressed
295
+ }
296
+ except Exception as e:
297
+ print(f"Error evaluating distress risk for {symbol}: {e}")
298
+ return {"riskLevel": "Low", "distressProbability": 15.0, "rawDistressScore": 0.1, "isDistressed": False}
299
+
300
+ def get_severity(prediction):
301
+ severity_map = {
302
+ "Urgency": "high",
303
+ "Scarcity": "medium",
304
+ "Social Proof": "low",
305
+ "Misdirection": "high",
306
+ "Obstruction": "critical",
307
+ "Sneaking": "critical",
308
+ "Forced Action": "critical"
309
+ }
310
+ return severity_map.get(prediction, "medium")
311
+
312
+ def get_compliance_metadata(prediction, text):
313
+ if prediction == "Urgency":
314
+ violation = "Urgency tactics create artificial pressure to force immediate transaction decisions, potentially violating 12 CFR 1041 prohibiting deceptive acts or practices."
315
+ recommendation = f"Remove countdown timers or false urgency text like '{text}'."
316
+ elif prediction == "Scarcity":
317
+ violation = "Scarcity tactics (e.g. artificial stock limits) manipulate consumers into immediate purchases, violating FTC Act Section 5 against deceptive practices."
318
+ recommendation = f"Ensure the statement '{text}' is backed by real-time inventory systems. If not verified, remove it."
319
+ elif prediction == "Social Proof":
320
+ violation = "Unverified social proof notifications (e.g. 'X bought this recently') can mislead consumers, violating general rules on deceptive advertisements."
321
+ recommendation = f"Validate that '{text}' is based on genuine user activity. Otherwise, disable this alert."
322
+ elif prediction == "Misdirection":
323
+ violation = "Misdirection visual/language design (like confirmshaming) steers users away from their intended choices, violating consumer choice principles."
324
+ recommendation = f"Change the option text in '{text}' to use clear and neutral language (e.g. 'Cancel' / 'Confirm') without guilt-tripping."
325
+ elif prediction == "Obstruction":
326
+ violation = "Obstruction (making cancellation or opt-out complex) violates EFTA and CFPB guidelines against hard-to-cancel billing structures."
327
+ recommendation = f"Simplify subscription cancellation related to '{text}'. The exit path should be as simple as the sign-up path."
328
+ elif prediction == "Sneaking":
329
+ violation = "Sneaking (adding hidden costs or pre-selected add-ons) violates EFTA and deceptive practices rules by charging without active consent."
330
+ recommendation = f"Ensure '{text}' does not lead to pre-checked options. Require explicit opt-in for all additional items or services."
331
+ elif prediction == "Forced Action":
332
+ violation = "Forced Action requires consumers to perform unrelated actions (e.g. consent to tracking) to finish a task, violating consumer choice guidelines."
333
+ recommendation = f"Allow users to proceed past '{text}' without mandatory signups or sharing non-essential data."
334
+ else:
335
+ violation = "General deceptive pattern detected that may violate CFPB guidelines against deceptive acts or practices."
336
+ recommendation = "Redesign copy and flow to maximize user transparency and choice."
337
+
338
+ return violation, recommendation
339
+
340
+ @app.route('/api/analyze', methods=['POST', 'OPTIONS'])
341
+ def analyze_image():
342
+ if request.method == 'OPTIONS':
343
+ return jsonify({}), 200
344
+
345
+ data = request.json
346
+ image_url = data.get('imageUrl', '')
347
+ print(f"\n📸 Received request for image analysis...")
348
+
349
+ try:
350
+ # Load image (handling both base64 Data URLs and HTTP URLs)
351
+ if image_url.startswith('data:image/'):
352
+ pattern = re.compile(r'^data:image/\w+;base64,(.*)$')
353
+ match = pattern.match(image_url)
354
+ if not match:
355
+ raise ValueError("Invalid data URL format")
356
+ img_data = base64.b64decode(match.group(1))
357
+ img = Image.open(BytesIO(img_data))
358
+ elif '/api/samples/' in image_url:
359
+ # Bundled demo screenshot load from disk to avoid the server calling itself
360
+ filename = os.path.basename(image_url.split('?')[0])
361
+ img = Image.open(os.path.join(script_dir, 'samples', filename))
362
+ else:
363
+ response = requests.get(image_url, timeout=10)
364
+ img = Image.open(BytesIO(response.content))
365
+
366
+ img_width, img_height = img.size
367
+ print(f"👁️ Image size: {img_width}x{img_height}. Scanning for text blocks...")
368
+
369
+ # Get OCR data (bounding box coordinates)
370
+ ocr_data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
371
+ extracted_text = pytesseract.image_to_string(img).strip()
372
+
373
+ # Group words by block and line number to reconstruct coherent lines
374
+ lines = {}
375
+ n_boxes = len(ocr_data['text'])
376
+ for i in range(n_boxes):
377
+ text = ocr_data['text'][i].strip()
378
+ if not text:
379
+ continue
380
+
381
+ block_num = ocr_data['block_num'][i]
382
+ line_num = ocr_data['line_num'][i]
383
+ key = (block_num, line_num)
384
+
385
+ left = ocr_data['left'][i]
386
+ top = ocr_data['top'][i]
387
+ width = ocr_data['width'][i]
388
+ height = ocr_data['height'][i]
389
+
390
+ if key not in lines:
391
+ lines[key] = {
392
+ 'words': [],
393
+ 'left': left,
394
+ 'top': top,
395
+ 'right': left + width,
396
+ 'bottom': top + height
397
+ }
398
+
399
+ lines[key]['words'].append(text)
400
+ lines[key]['left'] = min(lines[key]['left'], left)
401
+ lines[key]['top'] = min(lines[key]['top'], top)
402
+ lines[key]['right'] = max(lines[key]['right'], left + width)
403
+ lines[key]['bottom'] = max(lines[key]['bottom'], top + height)
404
+
405
+ dark_patterns = []
406
+ pattern_id = 1
407
+
408
+ for key, info in lines.items():
409
+ line_text = " ".join(info['words']).strip()
410
+ if len(line_text) < 3:
411
+ continue
412
+
413
+ # Predict pattern class
414
+ prediction = model.predict([line_text])[0]
415
+ if prediction != "Not Dark Pattern":
416
+ probs = model.predict_proba([line_text])[0]
417
+ classes = model.classes_
418
+ pred_idx = list(classes).index(prediction)
419
+ confidence_score = round(probs[pred_idx] * 100)
420
+
421
+ severity = get_severity(prediction)
422
+ violation, recommendation = get_compliance_metadata(prediction, line_text)
423
+
424
+ # Convert coords to percentages relative to image size
425
+ left_pct = round((info['left'] / img_width) * 100, 2)
426
+ top_pct = round((info['top'] / img_height) * 100, 2)
427
+ width_pct = round(((info['right'] - info['left']) / img_width) * 100, 2)
428
+ height_pct = round(((info['bottom'] - info['top']) / img_height) * 100, 2)
429
+
430
+ dark_patterns.append({
431
+ "id": str(pattern_id),
432
+ "type": prediction,
433
+ "severity": severity,
434
+ "description": f"Deceptive copywriting matching {prediction} pattern.",
435
+ "confidence": confidence_score,
436
+ "location": {
437
+ "x": left_pct,
438
+ "y": top_pct,
439
+ "width": width_pct,
440
+ "height": height_pct
441
+ },
442
+ "cfpbViolation": violation,
443
+ "recommendation": recommendation,
444
+ "evidence": line_text,
445
+ "explanation": explain_prediction(line_text, prediction)
446
+ })
447
+ pattern_id += 1
448
+
449
+ # Calculate trust score & compliance report
450
+ if not dark_patterns:
451
+ overall_score = 98
452
+ risk_level = "low"
453
+ compliance_report = {
454
+ "cfpbAlignment": 98,
455
+ "issues": [],
456
+ "recommendations": []
457
+ }
458
+ else:
459
+ deductions = {
460
+ "critical": 25,
461
+ "high": 15,
462
+ "medium": 10,
463
+ "low": 5
464
+ }
465
+ score_deduction = sum(deductions.get(p["severity"], 10) for p in dark_patterns)
466
+ overall_score = max(5, 100 - score_deduction)
467
+
468
+ if overall_score >= 80:
469
+ risk_level = "low"
470
+ elif overall_score >= 60:
471
+ risk_level = "medium"
472
+ elif overall_score >= 45:
473
+ risk_level = "high"
474
+ else:
475
+ risk_level = "critical"
476
+
477
+ issues = list(dict.fromkeys([p["cfpbViolation"] for p in dark_patterns]))
478
+ recommendations = list(dict.fromkeys([p["recommendation"] for p in dark_patterns]))
479
+
480
+ compliance_report = {
481
+ "cfpbAlignment": overall_score,
482
+ "issues": issues,
483
+ "recommendations": recommendations
484
+ }
485
+
486
+ return jsonify({
487
+ "imageUrl": image_url,
488
+ "extractedText": extracted_text or "No text detected in screenshot.",
489
+ "overallScore": overall_score,
490
+ "riskLevel": risk_level,
491
+ "darkPatterns": dark_patterns,
492
+ "complianceReport": compliance_report,
493
+ "timestamp": datetime.datetime.now().isoformat()
494
+ })
495
+
496
+ except Exception as e:
497
+ print(f"❌ Analysis failed: {e}")
498
+ return jsonify({"error": f"Failed to process image: {str(e)}"}), 500
499
+
500
+ @app.route('/api/samples/<path:filename>', methods=['GET'])
501
+ def get_sample(filename):
502
+ """Serve bundled demo screenshots so the class can try the auditor instantly."""
503
+ from flask import send_from_directory
504
+ samples_dir = os.path.join(script_dir, 'samples')
505
+ return send_from_directory(samples_dir, filename)
506
+
507
+
508
+ @app.route('/api/metrics', methods=['GET'])
509
+ def get_metrics():
510
+ """Model evaluation metrics from a held-out stratified 20% test split."""
511
+ if model_metrics is None:
512
+ return jsonify({"error": "Model metrics unavailable"}), 503
513
+ return jsonify(model_metrics)
514
+
515
+
516
+ @app.route('/api/analyze-text', methods=['POST', 'OPTIONS'])
517
+ def analyze_text():
518
+ """Classify raw text directly — lets the class try the model without a screenshot."""
519
+ if request.method == 'OPTIONS':
520
+ return jsonify({}), 200
521
+ data = request.json or {}
522
+ text = (data.get('text') or '').strip()
523
+ if len(text) < 3:
524
+ return jsonify({"error": "Text too short"}), 400
525
+ try:
526
+ prediction = model.predict([text])[0]
527
+ probs = model.predict_proba([text])[0]
528
+ classes = list(model.classes_)
529
+ confidence = round(probs[classes.index(prediction)] * 100, 1)
530
+ top3 = sorted(zip(classes, probs), key=lambda x: x[1], reverse=True)[:3]
531
+ result = {
532
+ "text": text,
533
+ "prediction": prediction,
534
+ "confidence": confidence,
535
+ "isDarkPattern": prediction != "Not Dark Pattern",
536
+ "topClasses": [{"label": c, "probability": round(p * 100, 1)} for c, p in top3],
537
+ "explanation": explain_prediction(text, prediction),
538
+ }
539
+ if prediction != "Not Dark Pattern":
540
+ violation, recommendation = get_compliance_metadata(prediction, text)
541
+ result["cfpbViolation"] = violation
542
+ result["recommendation"] = recommendation
543
+ return jsonify(result)
544
+ except Exception as e:
545
+ return jsonify({"error": str(e)}), 500
546
+
547
+
548
+ @app.route('/api/dataset', methods=['GET'])
549
+ def get_dataset():
550
+ query = request.args.get('q', '').strip()
551
+ category = request.args.get('category', '').strip()
552
+ limit = int(request.args.get('limit', 50))
553
+ offset = int(request.args.get('offset', 0))
554
+
555
+ try:
556
+ filtered_df = df
557
+ if query:
558
+ filtered_df = filtered_df[filtered_df['text'].str.contains(query, case=False, na=False)]
559
+ if category:
560
+ filtered_df = filtered_df[filtered_df['Pattern Category'].str.lower() == category.lower()]
561
+
562
+ total = len(filtered_df)
563
+ sliced_df = filtered_df.iloc[offset:offset+limit]
564
+
565
+ records = sliced_df.to_dict(orient='records')
566
+
567
+ # Get category counts for stats
568
+ counts = df['Pattern Category'].value_counts().to_dict()
569
+
570
+ return jsonify({
571
+ "status": "success",
572
+ "total": total,
573
+ "limit": limit,
574
+ "offset": offset,
575
+ "records": records,
576
+ "categoryCounts": counts
577
+ })
578
+ except Exception as e:
579
+ return jsonify({"status": "error", "message": str(e)}), 500
580
+
581
+ @app.route('/api/stock/<symbol>', methods=['GET'])
582
+ def get_stock_data(symbol):
583
+ try:
584
+ url = f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol.upper()}?range=1d&interval=5m"
585
+ headers = {
586
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
587
+ }
588
+ response = requests.get(url, headers=headers, timeout=10)
589
+
590
+ # If Yahoo Finance rate-limits (429) or fails, fallback to generating simulated stock metrics deterministically
591
+ if response.status_code != 200:
592
+ print(f"⚠️ Yahoo Finance API returned status {response.status_code} for {symbol}. Generating simulated fallback.")
593
+ import random
594
+ hash_val = sum(ord(c) for c in symbol.upper())
595
+ base_price = 50.0 + (hash_val % 450)
596
+
597
+ history = []
598
+ price = base_price
599
+ for i in range(20):
600
+ price = price * (1 + (random.random() * 0.04 - 0.02))
601
+ history.append({
602
+ "time": f"T-{20-i}m",
603
+ "price": round(price, 2)
604
+ })
605
+
606
+ current_price = price
607
+ price_change = price * 0.015
608
+ price_change_pct = 1.5
609
+
610
+ distress_info = get_distress_risk(symbol)
611
+ news_info = get_stock_news(symbol)
612
+
613
+ return jsonify({
614
+ "status": "success",
615
+ "symbol": symbol.upper(),
616
+ "price": round(current_price, 2),
617
+ "change": round(price_change, 2),
618
+ "changePercent": round(price_change_pct, 2),
619
+ "history": history,
620
+ "distress": distress_info,
621
+ "news": news_info,
622
+ "simulated": True
623
+ })
624
+
625
+ data = response.json()
626
+ if not data.get('chart') or not data['chart'].get('result'):
627
+ return jsonify({"status": "error", "message": "Invalid stock symbol or no data available."}), 404
628
+
629
+ result = data['chart']['result'][0]
630
+ meta = result.get('meta', {})
631
+ current_price = meta.get('regularMarketPrice', 0)
632
+ previous_close = meta.get('chartPreviousClose', current_price)
633
+ price_change = current_price - previous_close
634
+ price_change_pct = (price_change / previous_close) * 100 if previous_close else 0
635
+
636
+ timestamps = result.get('timestamp', [])
637
+ quotes = result.get('indicators', {}).get('quote', [{}])[0].get('close', [])
638
+
639
+ history = []
640
+ for t, val in zip(timestamps, quotes):
641
+ if val is not None:
642
+ time_str = datetime.datetime.fromtimestamp(t).strftime('%H:%M')
643
+ history.append({
644
+ "time": time_str,
645
+ "price": round(val, 2)
646
+ })
647
+
648
+ distress_info = get_distress_risk(symbol)
649
+ news_info = get_stock_news(symbol)
650
+
651
+ return jsonify({
652
+ "status": "success",
653
+ "symbol": symbol.upper(),
654
+ "price": round(current_price, 2),
655
+ "change": round(price_change, 2),
656
+ "changePercent": round(price_change_pct, 2),
657
+ "history": history,
658
+ "distress": distress_info,
659
+ "news": news_info
660
+ })
661
+ except Exception as e:
662
+ # Fallback if any internal python exception occurs
663
+ print(f"⚠️ Exception in get_stock_data for {symbol}: {e}. Generating simulated fallback.")
664
+ import random
665
+ hash_val = sum(ord(c) for c in symbol.upper())
666
+ base_price = 50.0 + (hash_val % 450)
667
+ history = []
668
+ price = base_price
669
+ for i in range(20):
670
+ price = price * (1 + (random.random() * 0.04 - 0.02))
671
+ history.append({
672
+ "time": f"T-{20-i}m",
673
+ "price": round(price, 2)
674
+ })
675
+ return jsonify({
676
+ "status": "success",
677
+ "symbol": symbol.upper(),
678
+ "price": round(price, 2),
679
+ "change": round(price * 0.015, 2),
680
+ "changePercent": 1.5,
681
+ "history": history,
682
+ "distress": get_distress_risk(symbol),
683
+ "news": get_stock_news(symbol),
684
+ "simulated": True
685
+ })
686
+
687
+ def format_volume(val):
688
+ try:
689
+ val_float = float(val)
690
+ if val_float >= 1e9:
691
+ return f"${val_float / 1e9:.2f} B"
692
+ elif val_float >= 1e6:
693
+ return f"${val_float / 1e6:.2f} M"
694
+ else:
695
+ return f"${val_float:,.0f}"
696
+ except Exception:
697
+ return "$0.00"
698
+
699
+ @app.route('/api/market/assets', methods=['GET'])
700
+ def get_market_assets():
701
+ print("📈 Fetching live market assets statistics...")
702
+ assets_def = [
703
+ {"symbol": "BTC-USD", "name": "Bitcoin", "type": "crypto", "basePrice": 67645.0, "baseChange": 1.4},
704
+ {"symbol": "ETH-USD", "name": "Ethereum", "type": "crypto", "basePrice": 3450.0, "baseChange": -0.8},
705
+ {"symbol": "SOL-USD", "name": "Solana", "type": "crypto", "basePrice": 165.20, "baseChange": 4.2},
706
+ {"symbol": "DOGE-USD", "name": "Dogecoin", "type": "crypto", "basePrice": 0.142, "baseChange": -2.1},
707
+ {"symbol": "NVDA", "name": "NVIDIA Corp.", "type": "stock", "basePrice": 120.50, "baseChange": 3.8},
708
+ {"symbol": "AAPL", "name": "Apple Inc.", "type": "stock", "basePrice": 175.20, "baseChange": -0.4},
709
+ {"symbol": "TSLA", "name": "Tesla Inc.", "type": "stock", "basePrice": 185.0, "baseChange": 0.5},
710
+ {"symbol": "COIN", "name": "Coinbase Global", "type": "stock", "basePrice": 220.40, "baseChange": -1.9}
711
+ ]
712
+
713
+ headers = {
714
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
715
+ }
716
+
717
+ output_assets = []
718
+
719
+ for asset in assets_def:
720
+ symbol = asset["symbol"]
721
+ base_price = asset["basePrice"]
722
+ base_change = asset["baseChange"]
723
+
724
+ price = base_price
725
+ change_pct = base_change
726
+ high_24h = base_price * 1.02
727
+ low_24h = base_price * 0.98
728
+ volume_val = 0.0
729
+ sparkline = []
730
+ is_simulated = True
731
+
732
+ # 1. Try Yahoo Finance Chart API
733
+ try:
734
+ url = f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol}?range=1d&interval=15m"
735
+ res = requests.get(url, headers=headers, timeout=4)
736
+ if res.status_code == 200:
737
+ data = res.json()
738
+ result = data['chart']['result'][0]
739
+ meta = result.get('meta', {})
740
+
741
+ current_price = meta.get('regularMarketPrice')
742
+ previous_close = meta.get('chartPreviousClose')
743
+
744
+ if current_price is not None and current_price > 0:
745
+ price = current_price
746
+ if previous_close is not None and previous_close > 0:
747
+ change_pct = ((current_price - previous_close) / previous_close) * 100
748
+
749
+ high_24h = meta.get('regularMarketDayHigh', price * 1.02)
750
+ low_24h = meta.get('regularMarketDayLow', price * 0.98)
751
+
752
+ vol = meta.get('regularMarketVolume', 0)
753
+ if asset["type"] == "stock":
754
+ # Stock volume is shares; multiply by price to get USD volume
755
+ volume_val = vol * price
756
+ else:
757
+ volume_val = vol
758
+
759
+ # Extract historical quotes for sparkline
760
+ quotes = result.get('indicators', {}).get('quote', [{}])[0].get('close', [])
761
+ clean_quotes = [round(val, 2 if price >= 1.0 else 4) for val in quotes if val is not None]
762
+
763
+ if len(clean_quotes) >= 10:
764
+ step = len(clean_quotes) / 10.0
765
+ sparkline = [clean_quotes[int(i * step)] for i in range(10)]
766
+ sparkline[-1] = clean_quotes[-1]
767
+ elif len(clean_quotes) > 0:
768
+ sparkline = clean_quotes
769
+
770
+ is_simulated = False
771
+ except Exception as e:
772
+ print(f"⚠️ Yahoo Finance failed for {symbol}: {e}")
773
+
774
+ # 2. Try Binance API as fallback for Crypto
775
+ if is_simulated and asset["type"] == "crypto":
776
+ try:
777
+ binance_sym = symbol.replace("-USD", "USDT")
778
+ url = f"https://api.binance.com/api/v3/ticker/24hr?symbol={binance_sym}"
779
+ res = requests.get(url, timeout=3)
780
+ if res.status_code == 200:
781
+ b_data = res.json()
782
+ price = float(b_data["lastPrice"])
783
+ change_pct = float(b_data["priceChangePercent"])
784
+ high_24h = float(b_data["highPrice"])
785
+ low_24h = float(b_data["lowPrice"])
786
+ volume_val = float(b_data["quoteVolume"]) # quoteVolume is USDT volume
787
+ is_simulated = False
788
+ print(f"✅ Fallback to Binance successful for {symbol}: Price = {price}")
789
+ except Exception as e:
790
+ print(f"⚠️ Binance fallback failed for {symbol}: {e}")
791
+
792
+ # 3. Fallback to Simulated Quote if all APIs failed
793
+ import random
794
+ if is_simulated:
795
+ price = price * (1 + (random.random() * 0.002 - 0.001))
796
+ high_24h = price * 1.02
797
+ low_24h = price * 0.98
798
+ # Use deterministic base volume
799
+ if symbol == "BTC-USD": volume_val = 28450210000
800
+ elif symbol == "ETH-USD": volume_val = 14120450000
801
+ elif symbol == "SOL-USD": volume_val = 3510800000
802
+ elif symbol == "DOGE-USD": volume_val = 1240150000
803
+ elif symbol == "NVDA": volume_val = 18540900000
804
+ elif symbol == "AAPL": volume_val = 8450600000
805
+ elif symbol == "TSLA": volume_val = 9210300000
806
+ else: volume_val = 2150400000
807
+
808
+ # Ensure we have a valid 10-point sparkline
809
+ if not sparkline or len(sparkline) < 10:
810
+ sparkline = []
811
+ hist_price = price * (1 - (change_pct / 100))
812
+ for i in range(10):
813
+ jitter = (random.random() * 0.02 - 0.01) * hist_price
814
+ sparkline.append(round(hist_price + (i * (price - hist_price)/9) + jitter, 2 if price >= 1.0 else 4))
815
+
816
+ # Format volume string
817
+ if volume_val > 0:
818
+ volume_str = format_volume(volume_val)
819
+ else:
820
+ if symbol == "BTC-USD": volume_str = "$28,450,210,000"
821
+ elif symbol == "ETH-USD": volume_str = "$14,120,450,000"
822
+ elif symbol == "SOL-USD": volume_str = "$3,510,800,000"
823
+ elif symbol == "DOGE-USD": volume_str = "$1,240,150,000"
824
+ elif symbol == "NVDA": volume_str = "$18,540,900,000"
825
+ elif symbol == "AAPL": volume_str = "$8,450,600,000"
826
+ elif symbol == "TSLA": volume_str = "$9,210,300,000"
827
+ else: volume_str = "$2,150,400,000"
828
+
829
+ # Calculate dynamic setups
830
+ price_str = f"{price:,.2f}" if price >= 1.0 else f"{price:,.4f}"
831
+ if change_pct >= 2.0:
832
+ buy_p = random.randint(65, 80)
833
+ sell_p = 100 - buy_p
834
+ momentum = "Bullish"
835
+ signal = "Strong Buy"
836
+ analysis = f"{asset['name']} is experiencing a powerful breakout, surging {change_pct:.2f}% to ${price_str}. High volume buying pressure ({buy_p}%) has overwhelmed key overhead resistance. Relative Strength Index (RSI) is expanding rapidly, confirming strong bullish momentum."
837
+ elif change_pct >= 0.2:
838
+ buy_p = random.randint(52, 64)
839
+ sell_p = 100 - buy_p
840
+ momentum = "Bullish"
841
+ signal = "Buy"
842
+ analysis = f"{asset['name']} maintains a positive structure, trading up {change_pct:.2f}% at ${price_str}. The asset is holding support above the 50-day moving average, with spot order book flow showing steady bid accumulation."
843
+ elif change_pct <= -2.0:
844
+ buy_p = random.randint(20, 38)
845
+ sell_p = 100 - buy_p
846
+ momentum = "Bearish"
847
+ signal = "Sell"
848
+ analysis = f"{asset['name']} has broken key support to the downside, dropping {change_pct:.2f}% to ${price_str}. Sellers are in full control with {sell_p}% volume pressure. Momentum indicators are oversold, but advise waiting for a bottom structure to form."
849
+ elif change_pct <= -0.2:
850
+ buy_p = random.randint(39, 47)
851
+ sell_p = 100 - buy_p
852
+ momentum = "Bearish"
853
+ signal = "Sell"
854
+ analysis = f"{asset['name']} is under minor distribution, trading down {change_pct:.2f}% at ${price_str}. Selling pressure is slightly elevated, suggesting continuation of a short-term consolidation pattern before buyers re-engage."
855
+ else:
856
+ buy_p = random.randint(48, 51)
857
+ sell_p = 100 - buy_p
858
+ momentum = "Neutral"
859
+ signal = "Hold"
860
+ analysis = f"{asset['name']} is moving in a tight sideways range, currently priced at ${price_str} ({change_pct:+.2f}%). Spot volume is balanced, indicating a neutral tug-of-war between bulls and bears with no clear trend direction."
861
+
862
+ # Assign warning metadata (theme based)
863
+ if symbol == "BTC-USD":
864
+ risk_lvl = "Low"
865
+ warnings = ["Urgency FOMO banners active on major brokers", "Stealth spread markups active on buy trades"]
866
+ elif symbol == "ETH-USD":
867
+ risk_lvl = "Low"
868
+ warnings = ["Deceptive staking yield advertisements (hidden locking fees)"]
869
+ elif symbol == "SOL-USD":
870
+ risk_lvl = "Low"
871
+ warnings = ["High transaction failure gas fee warnings omitted by UI"]
872
+ elif symbol == "DOGE-USD":
873
+ risk_lvl = "Medium"
874
+ warnings = ["Pressure pop-ups ('DOGE is spiking! Buy before it runs!') active"]
875
+ elif symbol == "NVDA":
876
+ risk_lvl = "Low"
877
+ warnings = ["Visual misdirection: hiding index correlation parameters"]
878
+ elif symbol == "AAPL":
879
+ risk_lvl = "Low"
880
+ warnings = ["Sneaked add-on fees (recurring equity analyst newsletter pre-checked)"]
881
+ elif symbol == "TSLA":
882
+ risk_lvl = "Low"
883
+ warnings = ["Deceptive countdown timers on pricing locked deals"]
884
+ else: # COIN
885
+ risk_lvl = "High"
886
+ warnings = ["Deceptive rating: suppressing distress warning under low risk badge", "Cart sneaking: $4.99 options analytics pre-checked"]
887
+
888
+ output_assets.append({
889
+ "symbol": symbol,
890
+ "name": asset["name"],
891
+ "type": asset["type"],
892
+ "price": round(price, 2 if price >= 1.0 else 4),
893
+ "change24h": round(change_pct, 2),
894
+ "volume24h": volume_str,
895
+ "high24h": round(high_24h, 2 if price >= 1.0 else 4),
896
+ "low24h": round(low_24h, 2 if price >= 1.0 else 4),
897
+ "sparkline": sparkline,
898
+ "buySellPattern": {
899
+ "buyPressure": buy_p,
900
+ "sellPressure": sell_p,
901
+ "momentum": momentum,
902
+ "signal": signal,
903
+ "analysis": analysis
904
+ },
905
+ "fintechWarnings": {
906
+ "riskLevel": risk_lvl,
907
+ "activePatterns": warnings
908
+ }
909
+ })
910
+
911
+ return jsonify({
912
+ "status": "success",
913
+ "assets": output_assets,
914
+ "timestamp": datetime.datetime.now().isoformat()
915
+ })
916
+
917
+ @app.route('/api/analyze-options', methods=['POST', 'OPTIONS'])
918
+ def analyze_options():
919
+ if request.method == 'OPTIONS':
920
+ return jsonify({}), 200
921
+
922
+ data = request.json
923
+ image_url = data.get('imageUrl', '')
924
+ print(f"\n📊 Received request for options analysis...")
925
+
926
+ extracted_text = ""
927
+ is_options_screenshot = False
928
+
929
+ try:
930
+ if image_url:
931
+ # Decode base64
932
+ if image_url.startswith('data:image/'):
933
+ pattern = re.compile(r'^data:image/\w+;base64,(.*)$')
934
+ match = pattern.match(image_url)
935
+ if not match:
936
+ raise ValueError("Invalid data URL format")
937
+ img_data = base64.b64decode(match.group(1))
938
+ img = Image.open(BytesIO(img_data))
939
+ else:
940
+ response = requests.get(image_url, timeout=10)
941
+ img = Image.open(BytesIO(response.content))
942
+
943
+ extracted_text = pytesseract.image_to_string(img).strip()
944
+
945
+ # Simple check if this is an options chain screenshot
946
+ lower_text = extracted_text.lower()
947
+ keywords = ["deribit", "option", "strike", "call", "put", "iv bid", "iv ask", "delta", "bid-ask"]
948
+ keyword_matches = sum(1 for kw in keywords if kw in lower_text)
949
+ if keyword_matches >= 2 or any(str(strike) in lower_text for strike in [65000, 66000, 67000, 68000, 69000, 70000]):
950
+ is_options_screenshot = True
951
+
952
+ except Exception as e:
953
+ print(f"⚠️ OCR extraction failed: {e}. Falling back to default options analysis.")
954
+ is_options_screenshot = False
955
+
956
+ # Default/simulated option chain values based on BTC at $67,645.00
957
+ # Perfect copy of Deribit screenshot data
958
+ spot_price = 67645.00
959
+ expiry_date = "03 Jun 2026"
960
+ time_to_expiry_hours = 16.7
961
+
962
+ # We will generate a structured grid for strikes: 65,000 to 75,000
963
+ strikes_data = [
964
+ {"strike": 65000, "callSize": 2.0, "callBid": 0.0375, "callAsk": 0.0460, "callIvBid": 69.0, "callIvAsk": 122.2, "putSize": 10.8, "putBid": 0.0011, "putAsk": 0.0013, "putIvBid": 66.3, "putIvAsk": 69.2},
965
+ {"strike": 66000, "callSize": 2.2, "callBid": 0.0235, "callAsk": 0.0315, "callIvBid": 62.5, "callIvAsk": 96.3, "putSize": 25.2, "putBid": 0.0024, "putAsk": 0.0028, "putIvBid": 59.8, "putIvAsk": 63.3},
966
+ {"strike": 67000, "callSize": 0.1, "callBid": 0.0145, "callAsk": 0.0155, "callIvBid": 51.5, "callIvAsk": 57.7, "putSize": 79.6, "putBid": 0.0050, "putAsk": 0.0060, "putIvBid": 51.6, "putIvAsk": 57.9},
967
+ {"strike": 68000, "callSize": 13.2, "callBid": 0.0060, "callAsk": 0.0070, "callIvBid": 47.8, "callIvAsk": 53.7, "putSize": 0.4, "putBid": 0.0115, "putAsk": 0.0120, "putIvBid": 49.3, "putIvAsk": 52.3},
968
+ {"strike": 69000, "callSize": 0.4, "callBid": 0.0018, "callAsk": 0.0021, "callIvBid": 46.6, "callIvAsk": 49.3, "putSize": 5.5, "putBid": 0.0150, "putAsk": 0.0180, "putIvBid": 39.4, "putIvAsk": 59.8},
969
+ {"strike": 70000, "callSize": 3.5, "callBid": 0.0009, "callAsk": 0.0011, "callIvBid": 46.8, "callIvAsk": 49.4, "putSize": 0.8, "putBid": 0.0270, "putAsk": 0.0300, "putIvBid": 31.5, "putIvAsk": 64.6},
970
+ {"strike": 71000, "callSize": 2.7, "callBid": 0.0002, "callAsk": 0.0003, "callIvBid": 55.0, "callIvAsk": 58.7, "putSize": 0.4, "putBid": 0.0485, "putAsk": 0.0515, "putIvBid": 50.0, "putIvAsk": 87.3},
971
+ {"strike": 72000, "callSize": 10.2, "callBid": 0.0001, "callAsk": 0.0002, "callIvBid": 55.9, "callIvAsk": 61.6, "putSize": 0.7, "putBid": 0.0630, "putAsk": 0.0660, "putIvBid": 50.0, "putIvAsk": 101.0}
972
+ ]
973
+
974
+ # Calculate Put-Call Ratio (PCR) and ATM Skew
975
+ # ATM strike is 68000 (closest to spot $67,645.00)
976
+ atm_strike = 68000
977
+ atm_opt = next((x for x in strikes_data if x["strike"] == atm_strike), strikes_data[3])
978
+
979
+ atm_call_iv = (atm_opt["callIvBid"] + atm_opt["callIvAsk"]) / 2
980
+ atm_put_iv = (atm_opt["putIvBid"] + atm_opt["putIvAsk"]) / 2
981
+ iv_skew = round(atm_put_iv - atm_call_iv, 2) # positive skew means Puts are more expensive than Calls (bearish fear)
982
+
983
+ total_call_size = sum(x["callSize"] for x in strikes_data)
984
+ total_put_size = sum(x["putSize"] for x in strikes_data)
985
+ pcr_ratio = round(total_put_size / total_call_size, 2) if total_call_size > 0 else 1.0
986
+
987
+ # Determine "When is a good time to buy and sell options"
988
+ signals = []
989
+ recommended_action = "Hold"
990
+ action_explanation = ""
991
+
992
+ if iv_skew > 1.5:
993
+ signals.append(f"Volatility Skew is highly positive (+{iv_skew}%), showing put option premiums are heavily inflated due to downside hedging demand (market fear).")
994
+ if pcr_ratio > 1.1:
995
+ recommended_action = "Sell Put Credit Spreads / Buy Calls"
996
+ action_explanation = "Fear is peaking (high IV skew + high Put-Call Ratio). This is historically a good time to SELL puts to collect high option premiums, or BUY call options at a discount as the underlying asset consolidates near support."
997
+ else:
998
+ recommended_action = "Sell Put Options (Income Harvest)"
999
+ action_explanation = "Put premiums are elevated. Sell put options or put spreads to harvest high volatility premium."
1000
+ elif iv_skew < -1.5:
1001
+ signals.append(f"Volatility Skew is negative ({iv_skew}%), showing call option premiums are inflated due to upside FOMO buying.")
1002
+ if pcr_ratio < 0.8:
1003
+ recommended_action = "Buy Put Options (Hedge) / Sell Calls"
1004
+ action_explanation = "Market euphoria is high. Call premiums are overpriced and Put options are cheap. It is a good time to BUY puts as a low-cost downside hedge or SELL covered calls to lock in yield."
1005
+ else:
1006
+ recommended_action = "Buy Puts / Sell Call Spreads"
1007
+ action_explanation = "Call premiums are inflated. Buy cheap puts to position for a reversion."
1008
+ else:
1009
+ signals.append(f"Volatility Skew is neutral ({iv_skew}%), indicating balanced demand between call and put options.")
1010
+ if pcr_ratio > 1.3:
1011
+ recommended_action = "Buy Calls (Contrarian)"
1012
+ action_explanation = "Put-Call ratio is heavily skewed to puts, indicating oversold sentiment. A good time to buy calls for a relief rally."
1013
+ elif pcr_ratio < 0.6:
1014
+ recommended_action = "Buy Puts (Contrarian)"
1015
+ action_explanation = "Put-Call ratio is heavily skewed to calls, indicating overbought hype. A good time to buy puts for a cooling off period."
1016
+ else:
1017
+ recommended_action = "Hold / Neutral"
1018
+ action_explanation = "Volatility and volume distributions are balanced. Standard market conditions. Avoid opening large directional options exposure; look for range-bound credit strategies."
1019
+
1020
+ # Identify dark patterns/compliance issues in the options layout
1021
+ compliance_issues = []
1022
+ compliance_recommendations = []
1023
+
1024
+ # 1. Hidden option markups (wide spreads)
1025
+ wide_spreads = False
1026
+ for x in strikes_data:
1027
+ call_mid = (x["callBid"] + x["callAsk"]) / 2
1028
+ call_spread_pct = ((x["callAsk"] - x["callBid"]) / call_mid) * 100 if call_mid > 0 else 0
1029
+ if call_spread_pct > 15:
1030
+ wide_spreads = True
1031
+ break
1032
+
1033
+ if wide_spreads or is_options_screenshot:
1034
+ compliance_issues.append("Stealth Option Markups: Bid-ask spreads on out-of-the-money options exceed 15% of the option's value, acting as a hidden fee (Sneaking).")
1035
+ compliance_recommendations.append("Disclose the bid-ask spread percentages in real-time next to the order button so retail traders understand the slippage fee.")
1036
+
1037
+ # 2. Urgency
1038
+ compliance_issues.append("Urgency Expiry Alerts: Countdown banner 'BTC-3JUN26 contracts expire in 16 hours! Lock in premium now!' creates artificial pressure (Urgency).")
1039
+ compliance_recommendations.append("Remove high-pressure countdown phrases like 'Lock in premium now' and replace with a standard, non-colored expiry date label.")
1040
+
1041
+ # 3. Complexity barrier
1042
+ compliance_issues.append("Obstruction of Key Information: Displaying Greek metrics (Delta, Gamma, Vega, Theta) and IV levels without tooltips or explanations confuses retail users into making risky leverage trades (Obstruction).")
1043
+ compliance_recommendations.append("Add interactive tooltips explaining what Delta, IV, and Bid/Ask spreads mean, along with a warning of the high risk of options trading.")
1044
+
1045
+ overall_score = 65
1046
+ risk_level = "medium"
1047
+
1048
+ return jsonify({
1049
+ "status": "success",
1050
+ "asset": "BTC",
1051
+ "spotPrice": spot_price,
1052
+ "expiryDate": expiry_date,
1053
+ "timeToExpiryHours": time_to_expiry_hours,
1054
+ "strikes": strikes_data,
1055
+ "ivSkew": iv_skew,
1056
+ "putCallRatio": pcr_ratio,
1057
+ "signal": {
1058
+ "recommendation": recommended_action,
1059
+ "explanation": action_explanation,
1060
+ "indicators": signals
1061
+ },
1062
+ "compliance": {
1063
+ "score": overall_score,
1064
+ "riskLevel": risk_level,
1065
+ "issues": compliance_issues,
1066
+ "recommendations": compliance_recommendations
1067
+ },
1068
+ "extractedText": extracted_text or "Simulated options chain screen text parsed."
1069
+ })
1070
+
1071
+ if os.path.exists(dist_dir):
1072
+ @app.route('/', defaults={'path': ''})
1073
+ @app.route('/<path:path>')
1074
+ def serve(path):
1075
+ if path != "" and os.path.exists(os.path.join(app.static_folder, path)):
1076
+ return app.send_static_file(path)
1077
+ else:
1078
+ return app.send_static_file('index.html')
1079
+
1080
+ if __name__ == '__main__':
1081
+ app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8000)), debug=True)
src/app/App.tsx CHANGED
@@ -1,114 +1,119 @@
1
- import { useState, useEffect } from 'react';
2
- import { UploadSection } from './components/UploadSection';
3
- import { DashboardHeader } from './components/DashboardHeader';
4
- import { AnalyzingAnimation } from './components/AnalyzingAnimation';
5
- import { Footer } from './components/Footer';
6
- import { AnalysisResults } from './components/AnalysisResults';
7
- import { ModelExplorer } from './components/ModelExplorer';
8
- import { MarketPrices } from './components/MarketPrices';
9
- import { AnalysisData } from './types/analysis';
10
- import { API_BASE_URL } from './config';
11
-
12
- function App() {
13
- const [activeTab, setActiveTab] = useState<'auditor' | 'market' | 'explorer'>('auditor');
14
- const [isDarkMode, setIsDarkMode] = useState(true); // default to dark theme
15
- const [analysisData, setAnalysisData] = useState<AnalysisData | null>(null);
16
- const [isAnalyzing, setIsAnalyzing] = useState(false);
17
- const [error, setError] = useState<string | null>(null);
18
-
19
- // Sync dark mode class
20
- useEffect(() => {
21
- if (isDarkMode) {
22
- document.documentElement.classList.add('dark');
23
- } else {
24
- document.documentElement.classList.remove('dark');
25
- }
26
- }, [isDarkMode]);
27
-
28
- const handleAnalyze = async (imageUrl: string) => {
29
- setIsAnalyzing(true);
30
- setError(null);
31
- setAnalysisData(null);
32
-
33
- try {
34
- // Direct call to local Python AI endpoint
35
- const response = await fetch(`${API_BASE_URL}/api/analyze`, {
36
- method: 'POST',
37
- headers: { 'Content-Type': 'application/json' },
38
- body: JSON.stringify({ imageUrl })
39
- });
40
-
41
- if (!response.ok) {
42
- throw new Error(`Server returned status: ${response.status}`);
43
- }
44
-
45
- const data = await response.json();
46
-
47
- if (data.error) throw new Error(data.error);
48
- setAnalysisData(data);
49
-
50
- } catch (err: any) {
51
- console.error(err);
52
- setError(err.message || 'Failed to connect to Python backend.');
53
- } finally {
54
- setIsAnalyzing(false);
55
- }
56
- };
57
-
58
- return (
59
- <div className="min-h-screen bg-background text-foreground pb-12 font-sans flex flex-col justify-between transition-colors duration-300">
60
- <div>
61
- {/* Unified Dashboard Header with Tab Switcher & Theme Toggle */}
62
- <DashboardHeader
63
- activeTab={activeTab}
64
- setActiveTab={(tab: any) => setActiveTab(tab)}
65
- isDarkMode={isDarkMode}
66
- setIsDarkMode={setIsDarkMode}
67
- />
68
-
69
- <main className="container mx-auto px-6 py-8 max-w-7xl">
70
- {activeTab === 'auditor' && (
71
- <div className="space-y-8">
72
- {/* Uploader Section */}
73
- <UploadSection onAnalyze={handleAnalyze} isAnalyzing={isAnalyzing} />
74
-
75
- {/* Error logs */}
76
- {error && (
77
- <div className="p-4 rounded-xl text-rose-500 bg-rose-500/10 border border-rose-500/25 text-xs font-semibold">
78
- <strong>Audit Failed:</strong> {error}
79
- </div>
80
- )}
81
-
82
- {/* AI Processing animation */}
83
- {isAnalyzing && (
84
- <div className="bg-card/40 backdrop-blur-md p-16 rounded-2xl border border-border flex flex-col items-center justify-center">
85
- <AnalyzingAnimation />
86
- <p className="text-muted-foreground mt-6 font-mono text-xs animate-pulse uppercase tracking-widest font-bold">
87
- Running OCR Boundary & NLP Vector Models...
88
- </p>
89
- </div>
90
- )}
91
-
92
- {/* Audit Diagnostic Results */}
93
- {analysisData && !isAnalyzing && (
94
- <AnalysisResults data={analysisData} />
95
- )}
96
- </div>
97
- )}
98
-
99
- {activeTab === 'market' && (
100
- <MarketPrices />
101
- )}
102
-
103
- {activeTab === 'explorer' && (
104
- <ModelExplorer />
105
- )}
106
- </main>
107
- </div>
108
-
109
- <Footer />
110
- </div>
111
- );
112
- }
113
-
114
- export default App;
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { UploadSection } from './components/UploadSection';
3
+ import { DashboardHeader } from './components/DashboardHeader';
4
+ import { AnalyzingAnimation } from './components/AnalyzingAnimation';
5
+ import { Footer } from './components/Footer';
6
+ import { AnalysisResults } from './components/AnalysisResults';
7
+ import { ModelExplorer } from './components/ModelExplorer';
8
+ import { ModelPerformance } from './components/ModelPerformance';
9
+ import { MarketPrices } from './components/MarketPrices';
10
+ import { AnalysisData } from './types/analysis';
11
+ import { API_BASE_URL } from './config';
12
+
13
+ function App() {
14
+ const [activeTab, setActiveTab] = useState<'auditor' | 'performance' | 'market' | 'explorer'>('auditor');
15
+ const [isDarkMode, setIsDarkMode] = useState(true); // default to dark theme
16
+ const [analysisData, setAnalysisData] = useState<AnalysisData | null>(null);
17
+ const [isAnalyzing, setIsAnalyzing] = useState(false);
18
+ const [error, setError] = useState<string | null>(null);
19
+
20
+ // Sync dark mode class
21
+ useEffect(() => {
22
+ if (isDarkMode) {
23
+ document.documentElement.classList.add('dark');
24
+ } else {
25
+ document.documentElement.classList.remove('dark');
26
+ }
27
+ }, [isDarkMode]);
28
+
29
+ const handleAnalyze = async (imageUrl: string) => {
30
+ setIsAnalyzing(true);
31
+ setError(null);
32
+ setAnalysisData(null);
33
+
34
+ try {
35
+ // Direct call to local Python AI endpoint
36
+ const response = await fetch(`${API_BASE_URL}/api/analyze`, {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ imageUrl })
40
+ });
41
+
42
+ if (!response.ok) {
43
+ throw new Error(`Server returned status: ${response.status}`);
44
+ }
45
+
46
+ const data = await response.json();
47
+
48
+ if (data.error) throw new Error(data.error);
49
+ setAnalysisData(data);
50
+
51
+ } catch (err: any) {
52
+ console.error(err);
53
+ setError(err.message || 'Failed to connect to Python backend.');
54
+ } finally {
55
+ setIsAnalyzing(false);
56
+ }
57
+ };
58
+
59
+ return (
60
+ <div className="min-h-screen bg-background text-foreground pb-12 font-sans flex flex-col justify-between transition-colors duration-300">
61
+ <div>
62
+ {/* Unified Dashboard Header with Tab Switcher & Theme Toggle */}
63
+ <DashboardHeader
64
+ activeTab={activeTab}
65
+ setActiveTab={(tab: any) => setActiveTab(tab)}
66
+ isDarkMode={isDarkMode}
67
+ setIsDarkMode={setIsDarkMode}
68
+ />
69
+
70
+ <main className="container mx-auto px-6 py-8 max-w-7xl">
71
+ {activeTab === 'auditor' && (
72
+ <div className="space-y-8">
73
+ {/* Uploader Section */}
74
+ <UploadSection onAnalyze={handleAnalyze} isAnalyzing={isAnalyzing} />
75
+
76
+ {/* Error logs */}
77
+ {error && (
78
+ <div className="p-4 rounded-xl text-rose-500 bg-rose-500/10 border border-rose-500/25 text-xs font-semibold">
79
+ <strong>Audit Failed:</strong> {error}
80
+ </div>
81
+ )}
82
+
83
+ {/* AI Processing animation */}
84
+ {isAnalyzing && (
85
+ <div className="bg-card/40 backdrop-blur-md p-16 rounded-2xl border border-border flex flex-col items-center justify-center">
86
+ <AnalyzingAnimation />
87
+ <p className="text-muted-foreground mt-6 font-mono text-xs animate-pulse uppercase tracking-widest font-bold">
88
+ Running OCR Boundary & NLP Vector Models...
89
+ </p>
90
+ </div>
91
+ )}
92
+
93
+ {/* Audit Diagnostic Results */}
94
+ {analysisData && !isAnalyzing && (
95
+ <AnalysisResults data={analysisData} />
96
+ )}
97
+ </div>
98
+ )}
99
+
100
+ {activeTab === 'performance' && (
101
+ <ModelPerformance />
102
+ )}
103
+
104
+ {activeTab === 'market' && (
105
+ <MarketPrices />
106
+ )}
107
+
108
+ {activeTab === 'explorer' && (
109
+ <ModelExplorer />
110
+ )}
111
+ </main>
112
+ </div>
113
+
114
+ <Footer />
115
+ </div>
116
+ );
117
+ }
118
+
119
+ export default App;
src/app/components/DarkPatternsList.tsx CHANGED
@@ -1,158 +1,179 @@
1
- import { DarkPattern } from '../types/analysis';
2
- import { Badge } from './ui/badge';
3
- import { AlertTriangle, Brain, FileText, Lightbulb } from 'lucide-react';
4
- import {
5
- Accordion,
6
- AccordionContent,
7
- AccordionItem,
8
- AccordionTrigger,
9
- } from './ui/accordion';
10
-
11
- interface DarkPatternsListProps {
12
- patterns: DarkPattern[];
13
- }
14
-
15
- export function DarkPatternsList({ patterns }: DarkPatternsListProps) {
16
- const getSeverityBadgeClass = (severity: string) => {
17
- switch (severity) {
18
- case 'critical':
19
- return 'bg-rose-500/10 text-rose-400 border border-rose-500/20';
20
- case 'high':
21
- return 'bg-orange-500/10 text-orange-400 border border-orange-500/20';
22
- case 'medium':
23
- return 'bg-amber-500/10 text-amber-400 border border-amber-500/20';
24
- default:
25
- return 'bg-indigo-500/10 text-indigo-400 border border-indigo-500/20';
26
- }
27
- };
28
-
29
- const getSeverityBorderColor = (severity: string) => {
30
- switch (severity) {
31
- case 'critical':
32
- return 'border-rose-500/25 bg-rose-500/5 hover:border-rose-500/40';
33
- case 'high':
34
- return 'border-orange-500/25 bg-orange-500/5 hover:border-orange-500/40';
35
- case 'medium':
36
- return 'border-amber-500/25 bg-amber-500/5 hover:border-amber-500/40';
37
- default:
38
- return 'border-indigo-500/25 bg-indigo-500/5 hover:border-indigo-500/40';
39
- }
40
- };
41
-
42
- // Sort by severity
43
- const sortedPatterns = [...patterns].sort((a, b) => {
44
- const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
45
- return severityOrder[a.severity] - severityOrder[b.severity];
46
- });
47
-
48
- return (
49
- <div className="space-y-6">
50
- {/* Summary */}
51
- <div className="bg-slate-900/30 rounded-xl p-4 border border-border">
52
- <div className="flex items-center gap-3">
53
- <AlertTriangle className="w-5 h-5 text-indigo-400" />
54
- <p className="text-xs text-slate-300">
55
- Identified <strong>{patterns.length} deceptive copy triggers</strong> in the layout layers.
56
- Expand each item below for compliance details and XAI fixes.
57
- </p>
58
- </div>
59
- </div>
60
-
61
- {/* Patterns List */}
62
- <Accordion type="single" collapsible className="space-y-4">
63
- {sortedPatterns.map((pattern) => (
64
- <AccordionItem
65
- key={pattern.id}
66
- value={pattern.id}
67
- className={`border rounded-xl overflow-hidden transition-all duration-300 ${getSeverityBorderColor(pattern.severity)}`}
68
- >
69
- <AccordionTrigger className="px-6 py-4 hover:no-underline text-slate-100">
70
- <div className="flex items-center gap-4 w-full text-left">
71
- <Badge className={`${getSeverityBadgeClass(pattern.severity)} text-[10px] font-bold px-2 py-0.5 rounded-md`}>
72
- {pattern.severity}
73
- </Badge>
74
- <div className="flex-1 min-w-0">
75
- <div className="font-bold text-xs sm:text-sm text-slate-200">{pattern.type}</div>
76
- <div className="text-[11px] text-slate-400 truncate mt-0.5">{pattern.description}</div>
77
- </div>
78
- <div className="text-[10px] font-mono text-slate-400 shrink-0 bg-slate-950/20 px-2 py-1 rounded border border-white/5">
79
- {pattern.confidence}% confidence
80
- </div>
81
- </div>
82
- </AccordionTrigger>
83
-
84
- <AccordionContent className="px-6 pb-6">
85
- <div className="space-y-4 pt-4 border-t border-white/5">
86
- {/* Description */}
87
- <div className="bg-slate-950/30 rounded-xl p-4 border border-white/5">
88
- <div className="flex items-start gap-3">
89
- <FileText className="w-4 h-4 text-indigo-400 shrink-0 mt-0.5" />
90
- <div>
91
- <h5 className="font-bold text-xs text-slate-200 mb-1">Issue Description</h5>
92
- <p className="text-xs text-slate-300">{pattern.description}</p>
93
- </div>
94
- </div>
95
- </div>
96
-
97
- {/* CFPB Violation */}
98
- <div className="bg-slate-950/30 rounded-xl p-4 border border-rose-500/15">
99
- <div className="flex items-start gap-3">
100
- <AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
101
- <div>
102
- <h5 className="font-bold text-xs text-rose-300 mb-1">CFPB Compliance Conflict</h5>
103
- <p className="text-xs text-rose-200/80 leading-relaxed">{pattern.cfpbViolation}</p>
104
- </div>
105
- </div>
106
- </div>
107
-
108
- {/* XAI Recommendation */}
109
- <div className="bg-slate-950/30 rounded-xl p-4 border border-emerald-500/15">
110
- <div className="flex items-start gap-3">
111
- <Lightbulb className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
112
- <div>
113
- <h5 className="font-bold text-xs text-emerald-300 mb-1">Recommended Fix</h5>
114
- <p className="text-xs text-emerald-200/80 leading-relaxed">{pattern.recommendation}</p>
115
- </div>
116
- </div>
117
- </div>
118
-
119
- {/* Location Info */}
120
- <div className="bg-slate-950/30 rounded-xl p-4 border border-white/5">
121
- <div className="flex items-start gap-3">
122
- <Brain className="w-4 h-4 text-purple-400 shrink-0 mt-0.5" />
123
- <div className="flex-1">
124
- <h5 className="font-bold text-xs text-slate-200 mb-2">Neural Network Activation Layer</h5>
125
- <div className="grid grid-cols-2 gap-3 text-xs">
126
- <div>
127
- <span className="text-slate-400">OCR Bounding Box:</span>{' '}
128
- <span className="text-slate-200 font-mono font-medium">
129
- ({pattern.location.x}%, {pattern.location.y}%)
130
- </span>
131
- </div>
132
- <div>
133
- <span className="text-slate-400">Visual Box Size:</span>{' '}
134
- <span className="text-slate-200 font-mono font-medium">
135
- {pattern.location.width}% × {pattern.location.height}%
136
- </span>
137
- </div>
138
- </div>
139
- </div>
140
- </div>
141
- </div>
142
- </div>
143
- </AccordionContent>
144
- </AccordionItem>
145
- ))}
146
- </Accordion>
147
-
148
- {/* About XAI */}
149
- <div className="bg-indigo-500/5 border border-indigo-500/10 rounded-xl p-4">
150
- <h4 className="font-bold text-xs text-indigo-300 mb-2">🤖 Neural Network Audit Mechanism</h4>
151
- <p className="text-xs text-slate-400 leading-relaxed">
152
- The models run OCR to extract text boundaries and classify the linguistic copy structure.
153
- All recommendations are generated by parsing structural cues against TILA, EFTA, and CFPB administrative records.
154
- </p>
155
- </div>
156
- </div>
157
- );
158
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DarkPattern } from '../types/analysis';
2
+ import { Badge } from './ui/badge';
3
+ import { AlertTriangle, Brain, FileText, Lightbulb } from 'lucide-react';
4
+ import {
5
+ Accordion,
6
+ AccordionContent,
7
+ AccordionItem,
8
+ AccordionTrigger,
9
+ } from './ui/accordion';
10
+
11
+ interface DarkPatternsListProps {
12
+ patterns: DarkPattern[];
13
+ }
14
+
15
+ export function DarkPatternsList({ patterns }: DarkPatternsListProps) {
16
+ const getSeverityBadgeClass = (severity: string) => {
17
+ switch (severity) {
18
+ case 'critical':
19
+ return 'bg-rose-500/10 text-rose-400 border border-rose-500/20';
20
+ case 'high':
21
+ return 'bg-orange-500/10 text-orange-400 border border-orange-500/20';
22
+ case 'medium':
23
+ return 'bg-amber-500/10 text-amber-400 border border-amber-500/20';
24
+ default:
25
+ return 'bg-indigo-500/10 text-indigo-400 border border-indigo-500/20';
26
+ }
27
+ };
28
+
29
+ const getSeverityBorderColor = (severity: string) => {
30
+ switch (severity) {
31
+ case 'critical':
32
+ return 'border-rose-500/25 bg-rose-500/5 hover:border-rose-500/40';
33
+ case 'high':
34
+ return 'border-orange-500/25 bg-orange-500/5 hover:border-orange-500/40';
35
+ case 'medium':
36
+ return 'border-amber-500/25 bg-amber-500/5 hover:border-amber-500/40';
37
+ default:
38
+ return 'border-indigo-500/25 bg-indigo-500/5 hover:border-indigo-500/40';
39
+ }
40
+ };
41
+
42
+ // Sort by severity
43
+ const sortedPatterns = [...patterns].sort((a, b) => {
44
+ const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
45
+ return severityOrder[a.severity] - severityOrder[b.severity];
46
+ });
47
+
48
+ return (
49
+ <div className="space-y-6">
50
+ {/* Summary */}
51
+ <div className="bg-slate-900/30 rounded-xl p-4 border border-border">
52
+ <div className="flex items-center gap-3">
53
+ <AlertTriangle className="w-5 h-5 text-indigo-400" />
54
+ <p className="text-xs text-slate-300">
55
+ Identified <strong>{patterns.length} deceptive copy triggers</strong> in the layout layers.
56
+ Expand each item below for compliance details and XAI fixes.
57
+ </p>
58
+ </div>
59
+ </div>
60
+
61
+ {/* Patterns List */}
62
+ <Accordion type="single" collapsible className="space-y-4">
63
+ {sortedPatterns.map((pattern) => (
64
+ <AccordionItem
65
+ key={pattern.id}
66
+ value={pattern.id}
67
+ className={`border rounded-xl overflow-hidden transition-all duration-300 ${getSeverityBorderColor(pattern.severity)}`}
68
+ >
69
+ <AccordionTrigger className="px-6 py-4 hover:no-underline text-slate-100">
70
+ <div className="flex items-center gap-4 w-full text-left">
71
+ <Badge className={`${getSeverityBadgeClass(pattern.severity)} text-[10px] font-bold px-2 py-0.5 rounded-md`}>
72
+ {pattern.severity}
73
+ </Badge>
74
+ <div className="flex-1 min-w-0">
75
+ <div className="font-bold text-xs sm:text-sm text-slate-200">{pattern.type}</div>
76
+ <div className="text-[11px] text-slate-400 truncate mt-0.5">{pattern.description}</div>
77
+ </div>
78
+ <div className="text-[10px] font-mono text-slate-400 shrink-0 bg-slate-950/20 px-2 py-1 rounded border border-white/5">
79
+ {pattern.confidence}% confidence
80
+ </div>
81
+ </div>
82
+ </AccordionTrigger>
83
+
84
+ <AccordionContent className="px-6 pb-6">
85
+ <div className="space-y-4 pt-4 border-t border-white/5">
86
+ {/* Detected copy + XAI phrases */}
87
+ {pattern.evidence && (
88
+ <div className="bg-slate-950/30 rounded-xl p-4 border border-indigo-500/15">
89
+ <h5 className="font-bold text-xs text-indigo-300 mb-2">Detected Copy</h5>
90
+ <p className="text-xs text-slate-200 font-mono leading-relaxed">"{pattern.evidence}"</p>
91
+ {pattern.explanation && pattern.explanation.length > 0 && (
92
+ <div className="flex flex-wrap gap-1.5 mt-3">
93
+ {pattern.explanation.map((e) => (
94
+ <span
95
+ key={e.phrase}
96
+ className="text-[10px] font-mono px-2 py-0.5 rounded bg-indigo-500/15 text-indigo-300 border border-indigo-500/25"
97
+ title={`contribution weight ${e.weight}`}
98
+ >
99
+ {e.phrase}
100
+ </span>
101
+ ))}
102
+ </div>
103
+ )}
104
+ </div>
105
+ )}
106
+
107
+ {/* Description */}
108
+ <div className="bg-slate-950/30 rounded-xl p-4 border border-white/5">
109
+ <div className="flex items-start gap-3">
110
+ <FileText className="w-4 h-4 text-indigo-400 shrink-0 mt-0.5" />
111
+ <div>
112
+ <h5 className="font-bold text-xs text-slate-200 mb-1">Issue Description</h5>
113
+ <p className="text-xs text-slate-300">{pattern.description}</p>
114
+ </div>
115
+ </div>
116
+ </div>
117
+
118
+ {/* CFPB Violation */}
119
+ <div className="bg-slate-950/30 rounded-xl p-4 border border-rose-500/15">
120
+ <div className="flex items-start gap-3">
121
+ <AlertTriangle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
122
+ <div>
123
+ <h5 className="font-bold text-xs text-rose-300 mb-1">CFPB Compliance Conflict</h5>
124
+ <p className="text-xs text-rose-200/80 leading-relaxed">{pattern.cfpbViolation}</p>
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ {/* XAI Recommendation */}
130
+ <div className="bg-slate-950/30 rounded-xl p-4 border border-emerald-500/15">
131
+ <div className="flex items-start gap-3">
132
+ <Lightbulb className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
133
+ <div>
134
+ <h5 className="font-bold text-xs text-emerald-300 mb-1">Recommended Fix</h5>
135
+ <p className="text-xs text-emerald-200/80 leading-relaxed">{pattern.recommendation}</p>
136
+ </div>
137
+ </div>
138
+ </div>
139
+
140
+ {/* Location Info */}
141
+ <div className="bg-slate-950/30 rounded-xl p-4 border border-white/5">
142
+ <div className="flex items-start gap-3">
143
+ <Brain className="w-4 h-4 text-purple-400 shrink-0 mt-0.5" />
144
+ <div className="flex-1">
145
+ <h5 className="font-bold text-xs text-slate-200 mb-2">Neural Network Activation Layer</h5>
146
+ <div className="grid grid-cols-2 gap-3 text-xs">
147
+ <div>
148
+ <span className="text-slate-400">OCR Bounding Box:</span>{' '}
149
+ <span className="text-slate-200 font-mono font-medium">
150
+ ({pattern.location.x}%, {pattern.location.y}%)
151
+ </span>
152
+ </div>
153
+ <div>
154
+ <span className="text-slate-400">Visual Box Size:</span>{' '}
155
+ <span className="text-slate-200 font-mono font-medium">
156
+ {pattern.location.width}% × {pattern.location.height}%
157
+ </span>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </AccordionContent>
165
+ </AccordionItem>
166
+ ))}
167
+ </Accordion>
168
+
169
+ {/* About XAI */}
170
+ <div className="bg-indigo-500/5 border border-indigo-500/10 rounded-xl p-4">
171
+ <h4 className="font-bold text-xs text-indigo-300 mb-2">🤖 Neural Network Audit Mechanism</h4>
172
+ <p className="text-xs text-slate-400 leading-relaxed">
173
+ The models run OCR to extract text boundaries and classify the linguistic copy structure.
174
+ All recommendations are generated by parsing structural cues against TILA, EFTA, and CFPB administrative records.
175
+ </p>
176
+ </div>
177
+ </div>
178
+ );
179
+ }
src/app/components/DashboardHeader.tsx CHANGED
@@ -1,82 +1,83 @@
1
- import { Shield, LayoutDashboard, Database, Cpu, Sun, Moon, TrendingUp } from 'lucide-react';
2
-
3
- interface DashboardHeaderProps {
4
- activeTab: string;
5
- setActiveTab: (tab: string) => void;
6
- isDarkMode: boolean;
7
- setIsDarkMode: (dark: boolean) => void;
8
- }
9
-
10
- export function DashboardHeader({ activeTab, setActiveTab, isDarkMode, setIsDarkMode }: DashboardHeaderProps) {
11
- const tabs = [
12
- { id: 'auditor', label: 'Screenshot Auditor', icon: LayoutDashboard },
13
- { id: 'market', label: 'Real-Time Markets', icon: TrendingUp },
14
- { id: 'explorer', label: 'Model & Dataset Explorer', icon: Database },
15
- ];
16
-
17
- return (
18
- <header className="sticky top-0 z-50 bg-card/60 backdrop-blur-md border-b border-border shadow-lg transition-colors duration-300">
19
- <div className="container mx-auto px-6 py-4 max-w-7xl">
20
- <div className="flex flex-col md:flex-row items-center justify-between gap-4">
21
- {/* Brand Info */}
22
- <div className="flex items-center gap-3">
23
- <div className="bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 p-2.5 rounded-xl shadow-[0_0_15px_rgba(99,102,241,0.4)]">
24
- <Shield className="w-7 h-7 text-white" />
25
- </div>
26
- <div>
27
- <div className="flex items-center gap-2">
28
- <h1 className="text-xl font-bold bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent">
29
- Fintech Dark Pattern Detector
30
- </h1>
31
- <span className="text-[10px] font-mono font-semibold bg-indigo-500/10 text-indigo-500 dark:text-indigo-400 border border-indigo-500/20 px-2 py-0.5 rounded-full">
32
- v1.2.0
33
- </span>
34
- </div>
35
- <p className="text-xs text-muted-foreground flex items-center gap-1.5 mt-0.5">
36
- <Cpu className="w-3.5 h-3.5 text-indigo-500 dark:text-indigo-400" />
37
- AI-Powered XAI Compliance Analyzer (TF-IDF & Logistic Regression)
38
- </p>
39
- </div>
40
- </div>
41
-
42
- {/* Navigation & Theme Switcher */}
43
- <div className="flex items-center gap-3">
44
- <nav className="flex bg-slate-900/10 dark:bg-slate-900/60 p-1.5 rounded-xl border border-border">
45
- {tabs.map((tab) => {
46
- const Icon = tab.icon;
47
- const isActive = activeTab === tab.id;
48
- return (
49
- <button
50
- key={tab.id}
51
- onClick={() => setActiveTab(tab.id)}
52
- className={`flex items-center gap-2 px-4 py-2 text-xs font-semibold rounded-lg transition-all duration-300 ${
53
- isActive
54
- ? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-md shadow-indigo-500/20 scale-[1.02]'
55
- : 'text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5'
56
- }`}
57
- >
58
- <Icon className="w-4 h-4" />
59
- {tab.label}
60
- </button>
61
- );
62
- })}
63
- </nav>
64
-
65
- {/* Theme Toggle Button */}
66
- <button
67
- onClick={() => setIsDarkMode(!isDarkMode)}
68
- className="p-2.5 rounded-xl border border-border bg-card hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-all duration-300 shadow-md flex items-center justify-center cursor-pointer"
69
- title={isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
70
- >
71
- {isDarkMode ? (
72
- <Sun className="w-4 h-4 text-amber-400" />
73
- ) : (
74
- <Moon className="w-4 h-4 text-indigo-500" />
75
- )}
76
- </button>
77
- </div>
78
- </div>
79
- </div>
80
- </header>
81
- );
82
- }
 
 
1
+ import { Shield, LayoutDashboard, Database, Cpu, Sun, Moon, TrendingUp, Gauge } from 'lucide-react';
2
+
3
+ interface DashboardHeaderProps {
4
+ activeTab: string;
5
+ setActiveTab: (tab: string) => void;
6
+ isDarkMode: boolean;
7
+ setIsDarkMode: (dark: boolean) => void;
8
+ }
9
+
10
+ export function DashboardHeader({ activeTab, setActiveTab, isDarkMode, setIsDarkMode }: DashboardHeaderProps) {
11
+ const tabs = [
12
+ { id: 'auditor', label: 'Screenshot Auditor', icon: LayoutDashboard },
13
+ { id: 'performance', label: 'Model Performance', icon: Gauge },
14
+ { id: 'market', label: 'Real-Time Markets', icon: TrendingUp },
15
+ { id: 'explorer', label: 'Model & Dataset Explorer', icon: Database },
16
+ ];
17
+
18
+ return (
19
+ <header className="sticky top-0 z-50 bg-card/60 backdrop-blur-md border-b border-border shadow-lg transition-colors duration-300">
20
+ <div className="container mx-auto px-6 py-4 max-w-7xl">
21
+ <div className="flex flex-col md:flex-row items-center justify-between gap-4">
22
+ {/* Brand Info */}
23
+ <div className="flex items-center gap-3">
24
+ <div className="bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 p-2.5 rounded-xl shadow-[0_0_15px_rgba(99,102,241,0.4)]">
25
+ <Shield className="w-7 h-7 text-white" />
26
+ </div>
27
+ <div>
28
+ <div className="flex items-center gap-2">
29
+ <h1 className="text-xl font-bold bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent">
30
+ Fintech Dark Pattern Detector
31
+ </h1>
32
+ <span className="text-[10px] font-mono font-semibold bg-indigo-500/10 text-indigo-500 dark:text-indigo-400 border border-indigo-500/20 px-2 py-0.5 rounded-full">
33
+ v1.2.0
34
+ </span>
35
+ </div>
36
+ <p className="text-xs text-muted-foreground flex items-center gap-1.5 mt-0.5">
37
+ <Cpu className="w-3.5 h-3.5 text-indigo-500 dark:text-indigo-400" />
38
+ AI-Powered XAI Compliance Analyzer (TF-IDF & Logistic Regression)
39
+ </p>
40
+ </div>
41
+ </div>
42
+
43
+ {/* Navigation & Theme Switcher */}
44
+ <div className="flex items-center gap-3">
45
+ <nav className="flex bg-slate-900/10 dark:bg-slate-900/60 p-1.5 rounded-xl border border-border">
46
+ {tabs.map((tab) => {
47
+ const Icon = tab.icon;
48
+ const isActive = activeTab === tab.id;
49
+ return (
50
+ <button
51
+ key={tab.id}
52
+ onClick={() => setActiveTab(tab.id)}
53
+ className={`flex items-center gap-2 px-4 py-2 text-xs font-semibold rounded-lg transition-all duration-300 ${
54
+ isActive
55
+ ? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-md shadow-indigo-500/20 scale-[1.02]'
56
+ : 'text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5'
57
+ }`}
58
+ >
59
+ <Icon className="w-4 h-4" />
60
+ {tab.label}
61
+ </button>
62
+ );
63
+ })}
64
+ </nav>
65
+
66
+ {/* Theme Toggle Button */}
67
+ <button
68
+ onClick={() => setIsDarkMode(!isDarkMode)}
69
+ className="p-2.5 rounded-xl border border-border bg-card hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-all duration-300 shadow-md flex items-center justify-center cursor-pointer"
70
+ title={isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
71
+ >
72
+ {isDarkMode ? (
73
+ <Sun className="w-4 h-4 text-amber-400" />
74
+ ) : (
75
+ <Moon className="w-4 h-4 text-indigo-500" />
76
+ )}
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </header>
82
+ );
83
+ }
src/app/components/ModelPerformance.tsx ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { Gauge, FlaskConical, Loader2, AlertTriangle, Sparkles, ShieldAlert, ShieldCheck } from 'lucide-react';
3
+ import { Card } from './ui/card';
4
+ import { Button } from './ui/button';
5
+ import { API_BASE_URL } from '../config';
6
+
7
+ interface ClassStats {
8
+ precision: number;
9
+ recall: number;
10
+ f1: number;
11
+ support: number;
12
+ }
13
+
14
+ interface Metrics {
15
+ modelName: string;
16
+ datasetSize: number;
17
+ numClasses: number;
18
+ testSize: number;
19
+ accuracy: number;
20
+ macroF1: number;
21
+ weightedF1: number;
22
+ perClass: Record<string, ClassStats>;
23
+ classDistribution: Record<string, number>;
24
+ confusionMatrix: { labels: string[]; matrix: number[][] };
25
+ }
26
+
27
+ interface TextResult {
28
+ text: string;
29
+ prediction: string;
30
+ confidence: number;
31
+ isDarkPattern: boolean;
32
+ topClasses: { label: string; probability: number }[];
33
+ explanation: { phrase: string; weight: number }[];
34
+ cfpbViolation?: string;
35
+ recommendation?: string;
36
+ }
37
+
38
+ const EXAMPLE_TEXTS = [
39
+ 'Hurry! Only 3 left in stock — sale ends in 10 minutes!',
40
+ '2,847 customers bought this in the last 24 hours',
41
+ 'No thanks, I hate saving money',
42
+ 'Your monthly statement is available in the documents section.',
43
+ ];
44
+
45
+ export function ModelPerformance() {
46
+ const [metrics, setMetrics] = useState<Metrics | null>(null);
47
+ const [metricsError, setMetricsError] = useState<string | null>(null);
48
+ const [text, setText] = useState('');
49
+ const [testing, setTesting] = useState(false);
50
+ const [result, setResult] = useState<TextResult | null>(null);
51
+ const [testError, setTestError] = useState<string | null>(null);
52
+
53
+ useEffect(() => {
54
+ fetch(`${API_BASE_URL}/api/metrics`)
55
+ .then((r) => {
56
+ if (!r.ok) throw new Error(`Server returned ${r.status}`);
57
+ return r.json();
58
+ })
59
+ .then(setMetrics)
60
+ .catch((e) => setMetricsError(e.message));
61
+ }, []);
62
+
63
+ const runTest = async (input?: string) => {
64
+ const value = (input ?? text).trim();
65
+ if (value.length < 3) return;
66
+ if (input) setText(input);
67
+ setTesting(true);
68
+ setTestError(null);
69
+ setResult(null);
70
+ try {
71
+ const r = await fetch(`${API_BASE_URL}/api/analyze-text`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ text: value }),
75
+ });
76
+ const data = await r.json();
77
+ if (!r.ok || data.error) throw new Error(data.error || `Server returned ${r.status}`);
78
+ setResult(data);
79
+ } catch (e: any) {
80
+ setTestError(e.message);
81
+ } finally {
82
+ setTesting(false);
83
+ }
84
+ };
85
+
86
+ const f1Color = (f1: number) =>
87
+ f1 >= 0.9 ? 'bg-emerald-500' : f1 >= 0.7 ? 'bg-amber-500' : 'bg-rose-500';
88
+
89
+ return (
90
+ <div className="space-y-8">
91
+ {/* ── Live Text Lab ─────────────────────────────────────────── */}
92
+ <Card className="p-8 bg-card/40 backdrop-blur-md border border-border shadow-xl rounded-2xl">
93
+ <h2 className="text-xl font-bold text-foreground mb-1 flex items-center gap-2">
94
+ <FlaskConical className="w-5 h-5 text-indigo-500 dark:text-indigo-400" />
95
+ Live Text Lab
96
+ </h2>
97
+ <p className="text-xs text-muted-foreground mb-6">
98
+ Paste any fintech copywriting and see what the model thinks — and which phrases drove the decision.
99
+ </p>
100
+
101
+ <div className="flex flex-col md:flex-row gap-3">
102
+ <input
103
+ value={text}
104
+ onChange={(e) => setText(e.target.value)}
105
+ onKeyDown={(e) => e.key === 'Enter' && runTest()}
106
+ placeholder='e.g. "Hurry! Only 2 left in stock!"'
107
+ className="flex-1 px-4 py-3 rounded-xl bg-muted/20 border border-border text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-indigo-500/60"
108
+ />
109
+ <Button
110
+ onClick={() => runTest()}
111
+ disabled={testing || text.trim().length < 3}
112
+ className="h-12 px-8 rounded-xl text-xs font-bold uppercase tracking-wider bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white"
113
+ >
114
+ {testing ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Classify'}
115
+ </Button>
116
+ </div>
117
+
118
+ <div className="flex flex-wrap gap-2 mt-3">
119
+ {EXAMPLE_TEXTS.map((t) => (
120
+ <button
121
+ key={t}
122
+ onClick={() => runTest(t)}
123
+ className="text-[11px] px-3 py-1.5 rounded-full border border-border bg-muted/10 text-muted-foreground hover:text-foreground hover:border-indigo-500/40 transition-colors"
124
+ >
125
+ {t}
126
+ </button>
127
+ ))}
128
+ </div>
129
+
130
+ {testError && (
131
+ <div className="mt-4 p-3 rounded-xl text-rose-500 bg-rose-500/10 border border-rose-500/25 text-xs font-semibold">
132
+ {testError}
133
+ </div>
134
+ )}
135
+
136
+ {result && (
137
+ <div className="mt-6 p-5 rounded-xl border border-border bg-muted/10 space-y-4">
138
+ <div className="flex items-center gap-3 flex-wrap">
139
+ {result.isDarkPattern ? (
140
+ <ShieldAlert className="w-6 h-6 text-rose-500" />
141
+ ) : (
142
+ <ShieldCheck className="w-6 h-6 text-emerald-500" />
143
+ )}
144
+ <span
145
+ className={`text-sm font-extrabold uppercase tracking-wide ${
146
+ result.isDarkPattern ? 'text-rose-500' : 'text-emerald-500'
147
+ }`}
148
+ >
149
+ {result.prediction}
150
+ </span>
151
+ <span className="text-xs font-mono text-muted-foreground">
152
+ {result.confidence}% confidence
153
+ </span>
154
+ </div>
155
+
156
+ {result.explanation.length > 0 && (
157
+ <div>
158
+ <div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2 flex items-center gap-1.5">
159
+ <Sparkles className="w-3 h-3" /> Phrases that triggered this verdict
160
+ </div>
161
+ <div className="flex flex-wrap gap-2">
162
+ {result.explanation.map((e) => (
163
+ <span
164
+ key={e.phrase}
165
+ className="text-xs font-mono px-2.5 py-1 rounded-md bg-indigo-500/10 text-indigo-500 dark:text-indigo-300 border border-indigo-500/25"
166
+ title={`weight ${e.weight}`}
167
+ >
168
+ {e.phrase}
169
+ </span>
170
+ ))}
171
+ </div>
172
+ </div>
173
+ )}
174
+
175
+ <div className="grid grid-cols-3 gap-2">
176
+ {result.topClasses.map((c) => (
177
+ <div key={c.label} className="p-2.5 rounded-lg bg-muted/20 border border-border">
178
+ <div className="text-[10px] text-muted-foreground truncate">{c.label}</div>
179
+ <div className="text-sm font-bold text-foreground">{c.probability}%</div>
180
+ </div>
181
+ ))}
182
+ </div>
183
+
184
+ {result.cfpbViolation && (
185
+ <div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/25 text-xs text-amber-600 dark:text-amber-400">
186
+ <strong>Compliance:</strong> {result.cfpbViolation}
187
+ </div>
188
+ )}
189
+ </div>
190
+ )}
191
+ </Card>
192
+
193
+ {/* ── Evaluation metrics ────────────────────────────────────── */}
194
+ <Card className="p-8 bg-card/40 backdrop-blur-md border border-border shadow-xl rounded-2xl">
195
+ <h2 className="text-xl font-bold text-foreground mb-1 flex items-center gap-2">
196
+ <Gauge className="w-5 h-5 text-indigo-500 dark:text-indigo-400" />
197
+ Model Performance
198
+ </h2>
199
+ <p className="text-xs text-muted-foreground mb-6">
200
+ Evaluated on a stratified held-out 20% test split, never seen during training.
201
+ </p>
202
+
203
+ {metricsError && (
204
+ <div className="p-4 rounded-xl text-rose-500 bg-rose-500/10 border border-rose-500/25 text-xs font-semibold">
205
+ Failed to load metrics: {metricsError}
206
+ </div>
207
+ )}
208
+
209
+ {!metrics && !metricsError && (
210
+ <div className="flex items-center justify-center py-16">
211
+ <Loader2 className="w-6 h-6 animate-spin text-indigo-500" />
212
+ </div>
213
+ )}
214
+
215
+ {metrics && (
216
+ <div className="space-y-8">
217
+ <div className="text-xs font-mono text-muted-foreground">{metrics.modelName}</div>
218
+
219
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
220
+ {[
221
+ { label: 'Accuracy', value: `${(metrics.accuracy * 100).toFixed(1)}%` },
222
+ { label: 'Macro F1', value: metrics.macroF1.toFixed(3) },
223
+ { label: 'Weighted F1', value: metrics.weightedF1.toFixed(3) },
224
+ { label: 'Training samples', value: metrics.datasetSize.toLocaleString() },
225
+ ].map((s) => (
226
+ <div key={s.label} className="p-4 rounded-xl bg-muted/10 border border-border">
227
+ <div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
228
+ {s.label}
229
+ </div>
230
+ <div className="text-2xl font-extrabold text-foreground mt-1">{s.value}</div>
231
+ </div>
232
+ ))}
233
+ </div>
234
+
235
+ {/* Per-class table */}
236
+ <div>
237
+ <h3 className="text-sm font-bold text-foreground mb-3">F1 score per pattern class</h3>
238
+ <div className="space-y-2">
239
+ {Object.entries(metrics.perClass)
240
+ .sort((a, b) => b[1].f1 - a[1].f1)
241
+ .map(([cls, s]) => (
242
+ <div key={cls} className="flex items-center gap-3">
243
+ <div className="w-36 text-xs font-semibold text-foreground truncate">{cls}</div>
244
+ <div className="flex-1 h-3 rounded-full bg-muted/30 overflow-hidden">
245
+ <div
246
+ className={`h-full rounded-full ${f1Color(s.f1)}`}
247
+ style={{ width: `${Math.max(s.f1 * 100, 2)}%` }}
248
+ />
249
+ </div>
250
+ <div className="w-12 text-xs font-mono text-foreground text-right">
251
+ {s.f1.toFixed(2)}
252
+ </div>
253
+ <div className="w-20 text-[10px] font-mono text-muted-foreground text-right">
254
+ n={s.support}
255
+ </div>
256
+ </div>
257
+ ))}
258
+ </div>
259
+ </div>
260
+
261
+ {/* Low-support warning — honest about dataset limitations */}
262
+ {Object.values(metrics.perClass).some((s) => s.support < 10) && (
263
+ <div className="flex items-start gap-2.5 p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/25">
264
+ <AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
265
+ <p className="text-xs text-amber-600 dark:text-amber-400 leading-relaxed">
266
+ Classes with very few test samples (low <span className="font-mono">n</span>) have
267
+ unreliable scores — the dataset is heavily imbalanced (e.g. Forced Action and
268
+ Sneaking have under 20 examples total). Collecting more examples for rare
269
+ patterns is our top data priority.
270
+ </p>
271
+ </div>
272
+ )}
273
+
274
+ {/* Confusion matrix */}
275
+ <div>
276
+ <h3 className="text-sm font-bold text-foreground mb-3">Confusion matrix (rows = truth, columns = prediction)</h3>
277
+ <div className="overflow-x-auto">
278
+ <table className="text-[10px] font-mono border-collapse">
279
+ <thead>
280
+ <tr>
281
+ <th className="p-1.5" />
282
+ {metrics.confusionMatrix.labels.map((l) => (
283
+ <th key={l} className="p-1.5 text-muted-foreground font-semibold max-w-16 truncate" title={l}>
284
+ {l.split(' ')[0]}
285
+ </th>
286
+ ))}
287
+ </tr>
288
+ </thead>
289
+ <tbody>
290
+ {metrics.confusionMatrix.matrix.map((row, i) => {
291
+ const rowMax = Math.max(...row, 1);
292
+ return (
293
+ <tr key={metrics.confusionMatrix.labels[i]}>
294
+ <td className="p-1.5 text-muted-foreground font-semibold text-right pr-3 whitespace-nowrap">
295
+ {metrics.confusionMatrix.labels[i]}
296
+ </td>
297
+ {row.map((v, j) => (
298
+ <td
299
+ key={j}
300
+ className={`p-1.5 text-center min-w-12 rounded ${
301
+ v === 0
302
+ ? 'text-muted-foreground/40'
303
+ : i === j
304
+ ? 'text-emerald-500 font-bold'
305
+ : 'text-rose-500 font-bold'
306
+ }`}
307
+ style={{
308
+ backgroundColor:
309
+ v > 0
310
+ ? i === j
311
+ ? `rgba(16,185,129,${0.08 + 0.3 * (v / rowMax)})`
312
+ : `rgba(244,63,94,${0.08 + 0.3 * (v / rowMax)})`
313
+ : undefined,
314
+ }}
315
+ >
316
+ {v}
317
+ </td>
318
+ ))}
319
+ </tr>
320
+ );
321
+ })}
322
+ </tbody>
323
+ </table>
324
+ </div>
325
+ </div>
326
+ </div>
327
+ )}
328
+ </Card>
329
+ </div>
330
+ );
331
+ }
src/app/components/UploadSection.tsx CHANGED
@@ -1,188 +1,189 @@
1
- import { useState } from 'react';
2
- import { Upload, Image as ImageIcon, Loader2, Shield } from 'lucide-react';
3
- import { Button } from './ui/button';
4
- import { Card } from './ui/card';
5
-
6
- interface UploadSectionProps {
7
- onAnalyze: (imageUrl: string) => void;
8
- isAnalyzing: boolean;
9
- }
10
-
11
- const SAMPLE_IMAGES = [
12
- {
13
- id: '1',
14
- name: 'Urgency & Scarcity Checkout',
15
- url: 'https://images.unsplash.com/photo-1563986768609-322da13575f3?w=800&q=80',
16
- description: 'Fintech checkout containing countdown timers and artificial item availability notifications.'
17
- },
18
- {
19
- id: '2',
20
- name: 'Pre-Selected Fees Flow',
21
- url: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&q=80',
22
- description: 'Trading account registration featuring pre-checked subscription add-ons and sneaking costs.'
23
- },
24
- {
25
- id: '3',
26
- name: 'Misdirecting Options Confirm',
27
- url: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&q=80',
28
- description: 'Subscription cancellation modal using guilt-inducing confirmshaming copywriting.'
29
- }
30
- ];
31
-
32
- export function UploadSection({ onAnalyze, isAnalyzing }: UploadSectionProps) {
33
- const [selectedSample, setSelectedSample] = useState<string | null>(null);
34
- const [uploadedImage, setUploadedImage] = useState<string | null>(null);
35
-
36
- const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
37
- const file = event.target.files?.[0];
38
- if (file) {
39
- const reader = new FileReader();
40
- reader.onload = (e) => {
41
- const result = e.target?.result as string;
42
- setUploadedImage(result);
43
- setSelectedSample(null);
44
- };
45
- reader.readAsDataURL(file);
46
- }
47
- };
48
-
49
- const handleSampleSelect = (url: string) => {
50
- setSelectedSample(url);
51
- setUploadedImage(null);
52
- };
53
-
54
- const handleAnalyze = () => {
55
- const imageUrl = uploadedImage || selectedSample;
56
- if (imageUrl) {
57
- onAnalyze(imageUrl);
58
- }
59
- };
60
-
61
- const activeImage = uploadedImage || selectedSample;
62
-
63
- return (
64
- <div className="space-y-6">
65
- <Card className="p-8 bg-card/40 backdrop-blur-md border border-border shadow-xl rounded-2xl relative overflow-hidden transition-colors duration-300">
66
- {/* Glow overlay */}
67
- <div className="absolute top-0 right-0 w-80 h-80 bg-indigo-500/10 rounded-full blur-3xl -z-10 pointer-events-none" />
68
- <div className="absolute bottom-0 left-0 w-80 h-80 bg-purple-500/5 rounded-full blur-3xl -z-10 pointer-events-none" />
69
-
70
- <h2 className="text-xl font-bold text-foreground mb-6 flex items-center gap-2">
71
- <Upload className="w-5 h-5 text-indigo-500 dark:text-indigo-400" />
72
- Audit New UI Interface
73
- </h2>
74
-
75
- <div className="grid md:grid-cols-2 gap-8">
76
- {/* Upload Area (Fits target image directly inside) */}
77
- <div className="space-y-4">
78
- <label
79
- htmlFor="file-upload"
80
- className={`relative flex flex-col items-center justify-center w-full h-72 border-2 border-dashed rounded-xl cursor-pointer overflow-hidden transition-all duration-300 ${
81
- isAnalyzing
82
- ? 'border-border bg-muted/10 cursor-not-allowed'
83
- : activeImage
84
- ? 'border-indigo-500/50 bg-slate-900/10 dark:bg-slate-950/40 shadow-inner'
85
- : 'border-border bg-muted/20 hover:border-indigo-500/50 hover:bg-muted/40'
86
- }`}
87
- >
88
- {activeImage ? (
89
- <>
90
- <img
91
- src={activeImage}
92
- alt="Uploaded UI Target"
93
- className="w-full h-full object-contain p-2"
94
- />
95
- {/* Hover Overlay */}
96
- <div className="absolute inset-0 bg-slate-950/50 opacity-0 hover:opacity-100 transition-opacity duration-300 flex flex-col items-center justify-center text-center p-4">
97
- <Upload className="w-8 h-8 text-indigo-400 mb-2" />
98
- <span className="text-xs font-extrabold text-white">Click or Drop to Replace Screenshot</span>
99
- </div>
100
- {/* Bottom Indicator badge */}
101
- <div className="absolute bottom-2 left-2">
102
- <span className="text-[9px] font-mono bg-indigo-600 text-white px-2.5 py-1 rounded-md uppercase tracking-wider font-extrabold shadow-lg border border-indigo-500/20">
103
- Target Interface Loaded
104
- </span>
105
- </div>
106
- </>
107
- ) : (
108
- <div className="flex flex-col items-center justify-center pt-5 pb-6 px-4 text-center">
109
- <div className="p-4 bg-indigo-500/5 rounded-2xl mb-4 border border-indigo-500/10">
110
- <Upload className="w-10 h-10 text-indigo-500 dark:text-indigo-400 animate-pulse" />
111
- </div>
112
- <p className="mb-2 text-sm text-foreground/80">
113
- <span className="font-semibold text-indigo-500 dark:text-indigo-400">Click to upload</span> or drag and drop
114
- </p>
115
- <p className="text-xs text-muted-foreground">PNG, JPG or GIF (MAX. 10MB)</p>
116
- </div>
117
- )}
118
- <input
119
- id="file-upload"
120
- type="file"
121
- className="hidden"
122
- accept="image/*"
123
- onChange={handleFileUpload}
124
- disabled={isAnalyzing}
125
- />
126
- </label>
127
- </div>
128
-
129
- {/* Sample Images */}
130
- <div className="space-y-4">
131
- <div className="flex items-center gap-2 mb-4">
132
- <ImageIcon className="w-5 h-5 text-indigo-500 dark:text-indigo-400" />
133
- <h3 className="font-semibold text-foreground">Select Fintech Sample Screen</h3>
134
- </div>
135
-
136
- <div className="space-y-3">
137
- {SAMPLE_IMAGES.map((sample) => (
138
- <button
139
- key={sample.id}
140
- onClick={() => handleSampleSelect(sample.url)}
141
- disabled={isAnalyzing}
142
- className={`w-full text-left p-4 rounded-xl border transition-all duration-300 relative overflow-hidden group ${
143
- selectedSample === sample.url
144
- ? 'border-indigo-500 bg-indigo-500/5 shadow-md shadow-indigo-500/5'
145
- : 'border-border bg-muted/10 hover:border-slate-400 dark:hover:border-slate-700 hover:bg-muted/20'
146
- } ${isAnalyzing ? 'opacity-50 cursor-not-allowed' : ''}`}
147
- >
148
- <div className="font-bold text-sm text-foreground mb-1 group-hover:text-indigo-500 dark:group-hover:text-indigo-400 transition-colors">
149
- {sample.name}
150
- </div>
151
- <div className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
152
- {sample.description}
153
- </div>
154
- </button>
155
- ))}
156
- </div>
157
- </div>
158
- </div>
159
-
160
- {/* Analyze Button */}
161
- <div className="mt-8 flex justify-center border-t border-border pt-6">
162
- <Button
163
- onClick={handleAnalyze}
164
- disabled={!activeImage || isAnalyzing}
165
- size="lg"
166
- className={`min-w-[240px] h-12 rounded-xl text-xs font-bold uppercase tracking-wider transition-all duration-300 ${
167
- !activeImage
168
- ? 'bg-muted text-muted-foreground cursor-not-allowed border border-border'
169
- : 'bg-gradient-to-r from-indigo-600 via-indigo-500 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white shadow-[0_0_20px_rgba(99,102,241,0.3)] hover:scale-[1.03]'
170
- }`}
171
- >
172
- {isAnalyzing ? (
173
- <>
174
- <Loader2 className="w-5 h-5 mr-2 animate-spin text-white" />
175
- Processing AI Auditing...
176
- </>
177
- ) : (
178
- <>
179
- <Shield className="w-5 h-5 mr-2 text-white" />
180
- Analyze Deceptive Copywriting
181
- </>
182
- )}
183
- </Button>
184
- </div>
185
- </Card>
186
- </div>
187
- );
 
188
  }
 
1
+ import { useState } from 'react';
2
+ import { Upload, Image as ImageIcon, Loader2, Shield } from 'lucide-react';
3
+ import { Button } from './ui/button';
4
+ import { Card } from './ui/card';
5
+ import { API_BASE_URL } from '../config';
6
+
7
+ interface UploadSectionProps {
8
+ onAnalyze: (imageUrl: string) => void;
9
+ isAnalyzing: boolean;
10
+ }
11
+
12
+ const SAMPLE_IMAGES = [
13
+ {
14
+ id: '1',
15
+ name: 'Urgency & Scarcity Checkout',
16
+ url: `${API_BASE_URL}/api/samples/checkout_urgency.png`,
17
+ description: 'Fintech checkout with a countdown timer, fake spot scarcity, live-viewer social proof and a confirmshaming opt-out.'
18
+ },
19
+ {
20
+ id: '2',
21
+ name: 'Confirmshaming Cancellation',
22
+ url: `${API_BASE_URL}/api/samples/cancel_confirmshaming.png`,
23
+ description: 'Subscription cancellation modal using guilt-inducing copy, loss framing and a buried 6-step cancel flow.'
24
+ },
25
+ {
26
+ id: '3',
27
+ name: 'Pre-Selected Fees Signup',
28
+ url: `${API_BASE_URL}/api/samples/signup_sneaking.png`,
29
+ description: 'Loan account signup with pre-checked paid add-ons, a trial that silently converts, and sneaked data sharing.'
30
+ }
31
+ ];
32
+
33
+ export function UploadSection({ onAnalyze, isAnalyzing }: UploadSectionProps) {
34
+ const [selectedSample, setSelectedSample] = useState<string | null>(null);
35
+ const [uploadedImage, setUploadedImage] = useState<string | null>(null);
36
+
37
+ const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
38
+ const file = event.target.files?.[0];
39
+ if (file) {
40
+ const reader = new FileReader();
41
+ reader.onload = (e) => {
42
+ const result = e.target?.result as string;
43
+ setUploadedImage(result);
44
+ setSelectedSample(null);
45
+ };
46
+ reader.readAsDataURL(file);
47
+ }
48
+ };
49
+
50
+ const handleSampleSelect = (url: string) => {
51
+ setSelectedSample(url);
52
+ setUploadedImage(null);
53
+ };
54
+
55
+ const handleAnalyze = () => {
56
+ const imageUrl = uploadedImage || selectedSample;
57
+ if (imageUrl) {
58
+ onAnalyze(imageUrl);
59
+ }
60
+ };
61
+
62
+ const activeImage = uploadedImage || selectedSample;
63
+
64
+ return (
65
+ <div className="space-y-6">
66
+ <Card className="p-8 bg-card/40 backdrop-blur-md border border-border shadow-xl rounded-2xl relative overflow-hidden transition-colors duration-300">
67
+ {/* Glow overlay */}
68
+ <div className="absolute top-0 right-0 w-80 h-80 bg-indigo-500/10 rounded-full blur-3xl -z-10 pointer-events-none" />
69
+ <div className="absolute bottom-0 left-0 w-80 h-80 bg-purple-500/5 rounded-full blur-3xl -z-10 pointer-events-none" />
70
+
71
+ <h2 className="text-xl font-bold text-foreground mb-6 flex items-center gap-2">
72
+ <Upload className="w-5 h-5 text-indigo-500 dark:text-indigo-400" />
73
+ Audit New UI Interface
74
+ </h2>
75
+
76
+ <div className="grid md:grid-cols-2 gap-8">
77
+ {/* Upload Area (Fits target image directly inside) */}
78
+ <div className="space-y-4">
79
+ <label
80
+ htmlFor="file-upload"
81
+ className={`relative flex flex-col items-center justify-center w-full h-72 border-2 border-dashed rounded-xl cursor-pointer overflow-hidden transition-all duration-300 ${
82
+ isAnalyzing
83
+ ? 'border-border bg-muted/10 cursor-not-allowed'
84
+ : activeImage
85
+ ? 'border-indigo-500/50 bg-slate-900/10 dark:bg-slate-950/40 shadow-inner'
86
+ : 'border-border bg-muted/20 hover:border-indigo-500/50 hover:bg-muted/40'
87
+ }`}
88
+ >
89
+ {activeImage ? (
90
+ <>
91
+ <img
92
+ src={activeImage}
93
+ alt="Uploaded UI Target"
94
+ className="w-full h-full object-contain p-2"
95
+ />
96
+ {/* Hover Overlay */}
97
+ <div className="absolute inset-0 bg-slate-950/50 opacity-0 hover:opacity-100 transition-opacity duration-300 flex flex-col items-center justify-center text-center p-4">
98
+ <Upload className="w-8 h-8 text-indigo-400 mb-2" />
99
+ <span className="text-xs font-extrabold text-white">Click or Drop to Replace Screenshot</span>
100
+ </div>
101
+ {/* Bottom Indicator badge */}
102
+ <div className="absolute bottom-2 left-2">
103
+ <span className="text-[9px] font-mono bg-indigo-600 text-white px-2.5 py-1 rounded-md uppercase tracking-wider font-extrabold shadow-lg border border-indigo-500/20">
104
+ Target Interface Loaded
105
+ </span>
106
+ </div>
107
+ </>
108
+ ) : (
109
+ <div className="flex flex-col items-center justify-center pt-5 pb-6 px-4 text-center">
110
+ <div className="p-4 bg-indigo-500/5 rounded-2xl mb-4 border border-indigo-500/10">
111
+ <Upload className="w-10 h-10 text-indigo-500 dark:text-indigo-400 animate-pulse" />
112
+ </div>
113
+ <p className="mb-2 text-sm text-foreground/80">
114
+ <span className="font-semibold text-indigo-500 dark:text-indigo-400">Click to upload</span> or drag and drop
115
+ </p>
116
+ <p className="text-xs text-muted-foreground">PNG, JPG or GIF (MAX. 10MB)</p>
117
+ </div>
118
+ )}
119
+ <input
120
+ id="file-upload"
121
+ type="file"
122
+ className="hidden"
123
+ accept="image/*"
124
+ onChange={handleFileUpload}
125
+ disabled={isAnalyzing}
126
+ />
127
+ </label>
128
+ </div>
129
+
130
+ {/* Sample Images */}
131
+ <div className="space-y-4">
132
+ <div className="flex items-center gap-2 mb-4">
133
+ <ImageIcon className="w-5 h-5 text-indigo-500 dark:text-indigo-400" />
134
+ <h3 className="font-semibold text-foreground">Select Fintech Sample Screen</h3>
135
+ </div>
136
+
137
+ <div className="space-y-3">
138
+ {SAMPLE_IMAGES.map((sample) => (
139
+ <button
140
+ key={sample.id}
141
+ onClick={() => handleSampleSelect(sample.url)}
142
+ disabled={isAnalyzing}
143
+ className={`w-full text-left p-4 rounded-xl border transition-all duration-300 relative overflow-hidden group ${
144
+ selectedSample === sample.url
145
+ ? 'border-indigo-500 bg-indigo-500/5 shadow-md shadow-indigo-500/5'
146
+ : 'border-border bg-muted/10 hover:border-slate-400 dark:hover:border-slate-700 hover:bg-muted/20'
147
+ } ${isAnalyzing ? 'opacity-50 cursor-not-allowed' : ''}`}
148
+ >
149
+ <div className="font-bold text-sm text-foreground mb-1 group-hover:text-indigo-500 dark:group-hover:text-indigo-400 transition-colors">
150
+ {sample.name}
151
+ </div>
152
+ <div className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
153
+ {sample.description}
154
+ </div>
155
+ </button>
156
+ ))}
157
+ </div>
158
+ </div>
159
+ </div>
160
+
161
+ {/* Analyze Button */}
162
+ <div className="mt-8 flex justify-center border-t border-border pt-6">
163
+ <Button
164
+ onClick={handleAnalyze}
165
+ disabled={!activeImage || isAnalyzing}
166
+ size="lg"
167
+ className={`min-w-[240px] h-12 rounded-xl text-xs font-bold uppercase tracking-wider transition-all duration-300 ${
168
+ !activeImage
169
+ ? 'bg-muted text-muted-foreground cursor-not-allowed border border-border'
170
+ : 'bg-gradient-to-r from-indigo-600 via-indigo-500 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white shadow-[0_0_20px_rgba(99,102,241,0.3)] hover:scale-[1.03]'
171
+ }`}
172
+ >
173
+ {isAnalyzing ? (
174
+ <>
175
+ <Loader2 className="w-5 h-5 mr-2 animate-spin text-white" />
176
+ Processing AI Auditing...
177
+ </>
178
+ ) : (
179
+ <>
180
+ <Shield className="w-5 h-5 mr-2 text-white" />
181
+ Analyze Deceptive Copywriting
182
+ </>
183
+ )}
184
+ </Button>
185
+ </div>
186
+ </Card>
187
+ </div>
188
+ );
189
  }
src/app/config.ts CHANGED
@@ -1,5 +1,7 @@
1
- // Dynamic API URL resolution for local and production environments
2
- export const API_BASE_URL = (import.meta.env.VITE_API_URL as string) ||
3
- (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
4
- ? 'http://127.0.0.1:8000'
5
- : window.location.origin);
 
 
 
1
+ // Dynamic API URL resolution for local and production environments.
2
+ // When the page is served by the Flask server itself (any port), the API lives
3
+ // on the same origin. Only the Vite dev server (port 5173) needs the explicit
4
+ // backend address.
5
+ const isViteDev = window.location.port === '5173';
6
+ export const API_BASE_URL = (import.meta.env.VITE_API_URL as string) ||
7
+ (isViteDev ? 'http://127.0.0.1:8000' : window.location.origin);
src/app/types/analysis.ts CHANGED
@@ -1,30 +1,32 @@
1
- export interface DarkPattern {
2
- id: string;
3
- type: string;
4
- severity: 'low' | 'medium' | 'high' | 'critical';
5
- description: string;
6
- location: {
7
- x: number;
8
- y: number;
9
- width: number;
10
- height: number;
11
- };
12
- cfpbViolation: string;
13
- recommendation: string;
14
- confidence: number;
15
- }
16
-
17
- export interface ComplianceReport {
18
- cfpbAlignment: number;
19
- issues: string[];
20
- recommendations: string[];
21
- }
22
-
23
- export interface AnalysisData {
24
- imageUrl: string;
25
- overallScore: number;
26
- riskLevel: 'low' | 'medium' | 'high' | 'critical';
27
- darkPatterns: DarkPattern[];
28
- complianceReport: ComplianceReport;
29
- timestamp: string;
30
- }
 
 
 
1
+ export interface DarkPattern {
2
+ id: string;
3
+ type: string;
4
+ severity: 'low' | 'medium' | 'high' | 'critical';
5
+ description: string;
6
+ location: {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ };
12
+ cfpbViolation: string;
13
+ recommendation: string;
14
+ confidence: number;
15
+ evidence?: string;
16
+ explanation?: { phrase: string; weight: number }[];
17
+ }
18
+
19
+ export interface ComplianceReport {
20
+ cfpbAlignment: number;
21
+ issues: string[];
22
+ recommendations: string[];
23
+ }
24
+
25
+ export interface AnalysisData {
26
+ imageUrl: string;
27
+ overallScore: number;
28
+ riskLevel: 'low' | 'medium' | 'high' | 'critical';
29
+ darkPatterns: DarkPattern[];
30
+ complianceReport: ComplianceReport;
31
+ timestamp: string;
32
+ }