# ml_utils.py, adding ensemble for URL scam detection import re import pickle import logging import numpy as np from typing import Dict, List, Tuple from urllib.parse import urlparse from pathlib import Path import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.pipeline import Pipeline from sklearn.model_selection import cross_val_score from xgboost import XGBClassifier logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # ── URL feature engineering ─────────────────────────────────────────────────── def extract_url_features(url: str) -> Dict: """Extract structured numerical features from a URL string.""" try: raw = url if not url.startswith(('http://', 'https://')): url = 'http://' + url p = urlparse(url) domain = p.netloc.lower() path = p.path.lower() full = url.lower() SECURITY_KW = ['login', 'verify', 'update', 'confirm', 'secure', 'account', 'kyc', 'banking', 'signin', 'validation'] BRAND_KW = ['hdfc', 'sbi', 'icici', 'paytm', 'amazon', 'google', 'microsoft', 'paypal', 'netflix', 'airtel', 'jio', 'phonepe', 'razorpay', 'flipkart', 'swiggy', 'zomato', 'axis', 'kotak', 'uidai', 'npci', 'irctc'] SUSP_TLDS = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top', '.pw', '.click', '.site', '.co', '.in'] FREE_TLDS = ['.tk', '.ml', '.ga', '.cf', '.gq'] return { 'length': int(len(raw)), 'dot_count': int(raw.count('.')), 'hyphen_count': int(raw.count('-')), 'slash_count': int(raw.count('/')), 'digit_count': int(sum(c.isdigit() for c in domain)), 'is_https': int(url.startswith('https://')), 'is_ip': int(bool(re.search(r'\d+\.\d+\.\d+\.\d+', domain))), 'subdomain_depth': int(max(len(domain.split('.')) - 2, 0)), 'has_security_kw': int(any(w in full for w in SECURITY_KW)), 'has_brand_kw': int(any(w in full for w in BRAND_KW)), 'suspicious_tld': int(any(domain.endswith(t) for t in SUSP_TLDS)), 'has_free_tld': int(any(domain.endswith(t) for t in FREE_TLDS)), 'path_length': int(len(p.path)), 'has_numbers_in_domain': int(bool(re.search(r'\d', domain.split('.')[0]))), 'hyphen_in_domain': int('-' in domain), 'multi_hyphens': int(domain.count('-') >= 2), 'at_sign': int('@' in url), 'double_slash_redirect': int('//' in p.path), 'query_length': int(len(p.query)), 'brand_plus_hyphen': int(any(w in domain and '-' in domain for w in BRAND_KW)), 'security_kw_in_path': int(any(w in path for w in SECURITY_KW)), } except Exception: return {k: 0 for k in [ 'length', 'dot_count', 'hyphen_count', 'slash_count', 'digit_count', 'is_https', 'is_ip', 'subdomain_depth', 'has_security_kw', 'has_brand_kw', 'suspicious_tld', 'has_free_tld', 'path_length', 'has_numbers_in_domain', 'hyphen_in_domain', 'multi_hyphens', 'at_sign', 'double_slash_redirect', 'query_length', 'brand_plus_hyphen', 'security_kw_in_path', ]} class ScamDetectionService: def __init__(self): logger.info("Loading models...") text_path = Path("spam_model.pkl") url_path = Path("url_ensemble.pkl") if text_path.exists(): with open(text_path, 'rb') as f: self.text_model = pickle.load(f) logger.info("Text model loaded.") else: logger.info("Training text model...") self.train_text_model() if url_path.exists(): with open(url_path, 'rb') as f: self.url_ensemble = pickle.load(f) logger.info("URL ensemble loaded.") else: logger.info("Training URL ensemble...") self.train_url_ensemble() self.suspicious_shorteners = [ 'bit.ly', 'tinyurl.com', 'short.link', 'tiny.cc', 'ow.ly', 'goo.gl', 't.co', 'rb.gy', 'is.gd', 'v.gd', 'cutt.ly' ] # (regex, risk_score, short_label, detailed_explanation) self.social_engineering_patterns: List[Tuple[str, float, str, str]] = [ # OTP / credential harvesting (r'\bshare.{0,20}otp\b', 0.90, "OTP request", "Asks you to share an OTP -- no legitimate org ever does this."), (r'\bconfirm.{0,20}(otp|pin|password)\b', 0.85, "Credential request", "Requests you confirm an OTP, PIN, or password -- classic phishing."), (r'\b(last\s*4|last\s*four).{0,20}(digit|card)\b', 0.85, "Card digit request", "Asks for last 4 digits of your card -- used to build to full card theft."), (r'\b(card\s*number|cvv|expiry)\b', 0.85, "Card detail request", "Requests card number, CVV, or expiry -- your bank will never ask over SMS."), (r'\botp\b', 0.45, "OTP mention", "Mentions OTP -- context suggests a transaction or verification prompt."), # Bank / govt impersonation (r'\b(bank|rbi|sbi|hdfc|icici|axis|kotak|pnb|boi).{0,30}(suspend|block|close|deactivat|restrict)\b', 0.88, "Bank account threat", "Claims your bank account is being suspended -- banks use official mail, not SMS links."), (r'\b(fraud\s*prevention|fraud\s*team|fraud\s*department|fraud\s*monitoring)\b', 0.75, "Fraud team impersonation", "Impersonates a bank fraud team to create panic and urgency."), (r'\b(kyc|know\s*your\s*customer).{0,20}(update|expire|pending|complet|verif)\b', 0.88, "KYC scam", "KYC update requests via SMS are almost always fraudulent."), (r'\baadhaar.{0,30}(link|update|verify|expire|deactivat|biometric)\b', 0.85, "Aadhaar scam", "UIDAI does not send Aadhaar deactivation or verification requests via SMS."), (r'\b(pan\s*card|pan).{0,30}(flag|block|verify|link|update)\b', 0.82, "PAN card scam", "PAN verification is done only through the income tax portal -- not SMS links."), (r'\b(rbi|reserve\s*bank).{0,30}(notice|compliance|regulat|review|audit)\b', 0.85, "RBI impersonation", "The RBI does not contact individuals directly via SMS for compliance or audits."), (r'\b(income\s*tax|it\s*department|tds).{0,30}(refund|notice|verif|confirm|scrutin)\b', 0.85, "Tax dept scam", "Income tax refunds and notices come through e-Filing portal, not SMS."), (r'\b(gst|epfo|uan|pf\s*deposit).{0,30}(verif|update|link|suspend|block)\b', 0.82, "Govt portal impersonation", "Legitimate EPFO/GST communications don't ask for verification via SMS links."), # Telecom (r'\b(sim|mobile).{0,30}(deactivat|block|suspend|port).{0,20}(kyc|verif|update)\b', 0.85, "SIM KYC scam", "TRAI and telecom operators don't deactivate SIMs via SMS verification links."), (r'\b(airtel|jio|vi|vodafone|bsnl).{0,30}(block|suspend|deactivat|kyc|port)\b', 0.82, "Telecom impersonation", "Telecom providers handle SIM issues at stores or official apps -- not SMS links."), # Digital arrest / legal threat (r'\b(cbi|cybercrime|enforcement\s*directorate|ed|police|court).{0,40}(case|notice|filed|investigation|arrest|prosecution)\b', 0.90, "Law enforcement impersonation", "CBI/ED/Police do not initiate legal proceedings via SMS. This is a 'digital arrest' scam."), (r'\b(section\s*420|fema|pmla|ipc|money\s*laundering).{0,40}(invest|notice|case|compli)\b', 0.88, "Legal threat scam", "Citing specific legal sections over SMS to create fear is a known fraud tactic."), (r'\b(legal\s*notice|warrant|fir)\b', 0.80, "Legal threat", "Legitimate legal notices arrive through official postal or court channels, not SMS."), # Account suspension / urgency (r'\b(account|service|upi|wallet).{0,20}(suspend|block|terminat|deactivat|restrict)\b', 0.75, "Account suspension threat", "Suspension threats via SMS are pressure tactics to make you act without thinking."), (r'\b(immediate|immediately|urgent|urgently).{0,30}(action|call|contact|verify|confirm)\b', 0.70, "Urgency pressure", "Manufactured urgency is the #1 social engineering tactic -- bypasses rational thinking."), (r'\bwithin\s*(1|2|24|30|48|72)\s*hours?\b', 0.65, "Time pressure", "Artificial deadlines pressure you into acting before you can verify."), # Fake helpline (r'\b(call|contact).{0,20}(helpline|support|team|officer|number).{0,30}\d{8,12}\b', 0.75, "Fake helpline", "Scammers publish fake helpline numbers -- always call from the official website."), # Prize / lottery (r'\b(won|win|winner|winning).{0,30}(prize|lottery|lucky|reward|cash|gift|iphone|samsung)\b', 0.90, "Lottery/prize scam", "You cannot win a lottery you didn't enter. Designed to get your personal details."), (r'\bcongratulations.{0,50}(won|selected|chosen|winner|shortlist)\b', 0.90, "Prize scam", "Unsolicited congratulations messages are almost universally fraudulent."), (r'\bclaim.{0,20}(prize|reward|cash|gift|money|amount)\b', 0.85, "Claim prize prompt", "Asking you to 'claim' a prize you weren't expecting is a classic advance fee setup."), # Job fraud (r'\b(work\s*from\s*home|earn.{0,10}per\s*day|daily\s*earning|part\s*time\s*job).{0,40}(register|fee|pay|deposit)\b', 0.85, "Job fee scam", "Legitimate jobs don't ask you to pay a registration fee upfront."), (r'\b(shortlisted|selected).{0,30}(job|position|role|data\s*entry).{0,30}(register|fee|limited)\b', 0.85, "Fake job shortlisting", "Unsolicited job shortlisting with urgency or a fee is a recruitment scam."), # Investment fraud (r'\b(invest.{0,20}(return|profit|earning)).{0,30}(guaranteed|assured|fixed)\b', 0.88, "Investment scam", "Guaranteed returns don't exist -- hallmark of financial fraud."), (r'\bdouble.{0,15}(money|investment|amount|profit)\b', 0.90, "Investment doubling scam", "No legitimate scheme doubles your money. This is a Ponzi/pyramid scam pattern."), (r'\b(crypto|trading|forex).{0,30}(group|signal|profit|return|earn).{0,30}(percent|%|lakh|crore)\b', 0.88, "Crypto trading scam", "Fake trading groups with guaranteed returns -- the 'pig butchering' scam pattern."), # Phishing links (r'\bclick.{0,20}(link|here|below).{0,20}(verify|confirm|update|claim|secure)\b', 0.80, "Phishing link", "Being directed to click a link to verify or claim something is a phishing setup."), (r'\b(update|confirm).{0,20}(personal|bank|card|account)\s*(detail|info|data)\b', 0.85, "Data harvesting", "Requests to 'update' personal or financial details via a link are data theft attempts."), # Customs/Courier fraud (r'\b(customs|clearance).{0,30}(charge|fee|pay|pending|release)\b', 0.85, "Customs fee scam", "Customs clearance fees via SMS are fake -- official notices come through couriers."), (r'\b(parcel|package|shipment|courier).{0,30}(stuck|hold|pending|failed).{0,30}(pay|fee|charge|verify)\b', 0.80, "Courier fraud", "Delivery failure messages asking you to pay or verify details are typically fraudulent."), # Tech support (r'\b(microsoft|apple|google).{0,30}(security|malicious|virus|malware|traffic).{0,30}(install|call|contact)\b', 0.88, "Tech support scam", "Microsoft/Apple/Google don't contact you about malware via SMS."), ] logger.info("All models ready.") # ── Text model training ─────────────────────────────────────────────────── def train_text_model(self): texts, labels = [], [] try: sms_df = pd.read_csv("spam.csv", encoding='latin-1')[['v1', 'v2']] sms_df.columns = ['label_raw', 'text'] sms_df['label'] = sms_df['label_raw'].map({'ham': 0, 'spam': 1}) texts += list(sms_df['text']) labels += list(sms_df['label']) logger.info(f"Loaded {len(sms_df)} SMS spam samples.") except FileNotFoundError: logger.warning("spam.csv not found.") try: new_df = pd.read_csv("scam_messages_complete_500.csv", encoding='latin-1') new_df.columns = [c.lower().strip() for c in new_df.columns] label_map = { 'SCAM': 1, 'LOOKS_GOOD_BUT_SUSPICIOUS': 1, 'SUSPICIOUS': 1, 'FISHY_BUT_LEGITIMATE': 0, 'LEGITIMATE': 0, } new_df['label_int'] = new_df['label'].map(label_map) new_df = new_df.dropna(subset=['label_int']) texts += list(new_df['message_text']) * 5 labels += list(new_df['label_int'].astype(int)) * 5 logger.info(f"Loaded {len(new_df)} India-specific scam samples (5x upweighted).") except FileNotFoundError: logger.warning("scam_messages_complete_500.csv not found.") if not texts: raise RuntimeError("No training data found.") self.text_model = Pipeline([ ('tfidf', TfidfVectorizer( max_features=5000, ngram_range=(1, 2), stop_words='english', min_df=1, sublinear_tf=True, )), ('clf', LogisticRegression( max_iter=1000, C=1.0, class_weight='balanced', )) ]) logger.info(f"Training text model on {len(texts)} samples...") self.text_model.fit(texts, labels) with open("spam_model.pkl", 'wb') as f: pickle.dump(self.text_model, f) logger.info("Text model saved -> spam_model.pkl") # ── URL ensemble training ───────────────────────────────────────────────── def train_url_ensemble(self): """ Three-model soft-voting ensemble on 250-row labeled URL dataset. Model A: char n-gram TF-IDF on URL string -> Logistic Regression Learns character-level patterns (e.g. 'hdfc-', '-kyc', '.xyz'). Model B: 21 engineered numerical features -> Random Forest Learns structural signals: hyphen count, TLD type, subdomain depth. Model C: word TF-IDF on (url + red_flags + domain_pattern) -> XGBoost Learns combined text+signal keyword interactions. Final score: average of three probability vectors. Override: if any single model has >= 0.85 confidence on MALICIOUS, use it. """ try: df = pd.read_csv("scam_urls_training_250.csv", encoding='latin-1') except FileNotFoundError: logger.warning("scam_urls_training_250.csv not found. URL ensemble disabled.") self.url_ensemble = None return label_map = {'LEGITIMATE': 0, 'SUSPICIOUS': 1, 'MALICIOUS': 2} df['label_int'] = df['label'].map(label_map) df = df.dropna(subset=['label_int']) df['label_int'] = df['label_int'].astype(int) urls = df['url'].tolist() labels = df['label_int'].tolist() # Model A: char n-gram TF-IDF + LR model_a = Pipeline([ ('tfidf', TfidfVectorizer( analyzer='char_wb', ngram_range=(3, 5), max_features=3000, sublinear_tf=True, )), ('clf', LogisticRegression( max_iter=1000, C=1.0, class_weight='balanced', )) ]) # Model B: engineered features + RF feat_matrix = np.array([ list(extract_url_features(u).values()) for u in urls ], dtype=float) model_b = RandomForestClassifier( n_estimators=200, max_depth=8, class_weight='balanced', random_state=42, ) # Model C: combined text + XGBoost combined_text = [ f"{row['url']} {row.get('red_flags', '')} {row.get('domain_pattern', '')}" for _, row in df.iterrows() ] tfidf_c = TfidfVectorizer( analyzer='word', ngram_range=(1, 2), max_features=2000, sublinear_tf=True, ) feat_c = tfidf_c.fit_transform(combined_text) model_c = XGBClassifier( n_estimators=150, max_depth=4, learning_rate=0.1, eval_metric='mlogloss', random_state=42, verbosity=0, ) logger.info("Training Model A (char TF-IDF + LR)...") model_a.fit(urls, labels) cv_a = cross_val_score(model_a, urls, labels, cv=3, scoring='balanced_accuracy').mean() logger.info(f" Model A 3-fold balanced accuracy: {cv_a:.3f}") logger.info("Training Model B (engineered features + RF)...") model_b.fit(feat_matrix, labels) cv_b = cross_val_score(model_b, feat_matrix, labels, cv=3, scoring='balanced_accuracy').mean() logger.info(f" Model B 3-fold balanced accuracy: {cv_b:.3f}") logger.info("Training Model C (combined text + XGBoost)...") model_c.fit(feat_c, labels) cv_c = cross_val_score(model_c, feat_c, labels, cv=3, scoring='balanced_accuracy').mean() logger.info(f" Model C 3-fold balanced accuracy: {cv_c:.3f}") self.url_ensemble = { 'model_a': model_a, 'model_b': model_b, 'model_b_feat_names': list(extract_url_features(urls[0]).keys()), 'model_c_tfidf': tfidf_c, 'model_c': model_c, 'label_map_inv': {0: 'LEGITIMATE', 1: 'SUSPICIOUS', 2: 'MALICIOUS'}, 'cv_scores': {'model_a': cv_a, 'model_b': cv_b, 'model_c': cv_c}, } with open("url_ensemble.pkl", 'wb') as f: pickle.dump(self.url_ensemble, f) logger.info( f"URL ensemble saved -> url_ensemble.pkl " f"(A={cv_a:.3f}, B={cv_b:.3f}, C={cv_c:.3f})" ) # ── Helpers ─────────────────────────────────────────────────────────────── def _check_social_engineering(self, text: str) -> Tuple[float, List[str], List[str]]: text_lower = text.lower() short_reasons, detailed_reasons = [], [] max_score = 0.0 for pattern, score, short, detail in self.social_engineering_patterns: if re.search(pattern, text_lower): if short not in short_reasons: short_reasons.append(short) detailed_reasons.append(detail) max_score = max(max_score, score) return max_score, short_reasons, detailed_reasons def detect_language(self, text: str) -> str: if re.search(r'[\u0900-\u097F]', text): return 'hi' elif re.search(r'[\u0980-\u09FF]', text): return 'or' return 'en' def _ensemble_url_predict(self, url: str, red_flags_hint: str = "") -> Tuple[float, float, float]: """Returns (p_legitimate, p_suspicious, p_malicious). Falls back to safe if ensemble missing.""" if not self.url_ensemble: return 1.0, 0.0, 0.0 e = self.url_ensemble combined = f"{url} {red_flags_hint}" pa = e['model_a'].predict_proba([url])[0] feats = np.array([list(extract_url_features(url).values())], dtype=float) pb = e['model_b'].predict_proba(feats)[0] feat_c = e['model_c_tfidf'].transform([combined]) pc = e['model_c'].predict_proba(feat_c)[0] avg = (pa + pb + pc) / 3.0 # Single-model override if very confident on MALICIOUS for probs in [pa, pb, pc]: if probs[2] >= 0.85: avg = probs break return float(avg[0]), float(avg[1]), float(avg[2]) # ── Text analysis ───────────────────────────────────────────────────────── def analyze_text_scam(self, text: str, language: str = None) -> Dict: if not text or not text.strip(): return { "risk_level": "Safe", "confidence": 0.0, "reasoning": "Empty text.", "user_message": "Nothing to analyze.", "detected_language": "unknown", } detected_language = language or self.detect_language(text) try: proba = self.text_model.predict_proba([text])[0] spam_prob = proba[1] se_score, se_short, se_detailed = self._check_social_engineering(text) effective = max(spam_prob, se_score) if effective >= 0.55: risk_level, confidence = "Scam", effective elif effective >= 0.35: risk_level, confidence = "Suspicious", effective else: risk_level, confidence = "Safe", 1 - effective reasoning = f"Spam probability: {round(spam_prob * 100, 1)}%" if se_short: reasoning += f" | Flags: {', '.join(se_short[:3])}" if risk_level == "Scam": if se_detailed: extra = f" Also flagged: {', '.join(se_short[1:3])}." if len(se_short) > 1 else "" user_message = f"{se_detailed[0]}{extra} Do not share any details or click any links." else: user_message = f"Model confidence {round(spam_prob*100)}%. Multiple scam signals detected. Do not share details or click links." elif risk_level == "Suspicious": if se_detailed: user_message = f"Flagged for: {', '.join(se_short[:3])}. {se_detailed[0]} Verify independently before responding." else: user_message = f"Some characteristics match spam patterns (score: {round(spam_prob*100)}%). Worth a second look." else: user_message = "No scam signals detected. Still be cautious -- if something feels off, verify through official channels." return { "risk_level": risk_level, "confidence": round(confidence, 4), "reasoning": reasoning, "user_message": user_message, "detected_language": detected_language, } except Exception as e: logger.error(f"Text classification failed: {e}") return { "risk_level": "Suspicious", "confidence": 0.5, "reasoning": f"Model error: {e}", "user_message": "Could not analyze this message. Treat with caution.", "detected_language": detected_language, } # ── URL analysis (ensemble + rules blended) ─────────────────────────────── def analyze_url_scam(self, url: str, context: str = "") -> Dict: if not url: return { "risk_level": "Safe", "confidence": 0.0, "reasoning": "No URL provided.", "user_message": "Nothing to analyze.", "domain": "", "url_status": "invalid", } try: if not url.startswith(('http://', 'https://')): url = 'http://' + url domain = urlparse(url).netloc.lower() # Rule layer rule_risk = 0.0 rule_flags = [] if url.startswith('http://') and not url.startswith('https://'): rule_risk += 0.30 rule_flags.append(("HTTP not HTTPS", "Connection is unencrypted. Any data you enter can be intercepted.")) scam_domain_patterns = [ "faceb00k", "paypa1", "amaz0n", "micros0ft", "g00gle", "appleid", "login-secure", "claim-your", "verify-account", "lottery", "techsupport", "quickloan", "account-update", "hdfc-", "sbi-", "icici-", "paytm-", "netflix-payment", "bluedart-track", "india-post", ] matched = [p for p in scam_domain_patterns if p in domain] if matched: rule_risk += 0.85 rule_flags.append(("Brand spoofing", f"Domain impersonates a trusted brand ({matched[0]}). Use the official domain.")) shortener = next((s for s in self.suspicious_shorteners if s in domain), None) if shortener: rule_risk += 0.55 rule_flags.append(("URL shortener", f"Uses {shortener} -- hides the real destination.")) if re.search(r'\d+\.\d+\.\d+\.\d+', domain): rule_risk += 0.90 rule_flags.append(("Raw IP address", "Legitimate services never use raw IP addresses.")) suspicious_tlds = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top', '.pw', '.click', '.info', '.site'] matched_tld = next((t for t in suspicious_tlds if domain.endswith(t)), None) if matched_tld: rule_risk += 0.65 rule_flags.append(("Suspicious TLD", f"'{matched_tld}' is commonly used in phishing campaigns.")) if len(url) > 100: rule_risk += 0.25 rule_flags.append(("Abnormally long URL", "Very long URLs with many parameters are a common obfuscation tactic.")) security_words = ['login', 'verify', 'update', 'confirm', 'secure', 'account', 'signin', 'banking'] matched_kw = [w for w in security_words if w in url.lower()] if matched_kw: rule_risk += 0.35 rule_flags.append(("Security keywords in URL", f"Contains '{matched_kw[0]}' -- phishing pages use these to appear legitimate.")) # Ensemble prediction p_legit, p_susp, p_mal = self._ensemble_url_predict(url) ensemble_risk = p_mal * 1.0 + p_susp * 0.5 # 50/50 blend final_score = min(0.50 * min(rule_risk, 1.0) + 0.50 * ensemble_risk, 1.0) # Context boost from message analysis context_result = None if context: context_result = self.analyze_text_scam(context) if context_result['risk_level'] == 'Scam': final_score = min(final_score + 0.15, 1.0) elif context_result['risk_level'] == 'Suspicious': final_score = min(final_score + 0.07, 1.0) if final_score >= 0.55: risk_level = "Scam" elif final_score >= 0.25: risk_level = "Suspicious" else: risk_level = "Safe" flag_labels = [f[0] for f in rule_flags] reasoning = "; ".join(flag_labels) if flag_labels else "No rule flags" reasoning += (f" | Ensemble: {round(p_mal*100)}% malicious, " f"{round(p_susp*100)}% suspicious") if context_result: reasoning += f" | Message context: {context_result['risk_level']}" if risk_level == "Scam": primary = rule_flags[0][1] if rule_flags else "High-risk URL detected by ensemble classifier." extras = [f[0] for f in rule_flags[1:3]] extra_s = f" Also: {', '.join(extras)}." if extras else "" user_message = f"{primary}{extra_s} Do not open this link." elif risk_level == "Suspicious": primary = rule_flags[0][1] if rule_flags else "URL has unusual structural characteristics." user_message = f"{primary} Verify this is from an official source before clicking." else: user_message = "No major red flags detected. Still, only click links from sources you initiated contact with." return { "risk_level": risk_level, "confidence": round(final_score, 4), "reasoning": reasoning, "user_message": user_message, "domain": domain, "url_status": "analyzed", "ensemble_scores": { "p_legitimate": round(p_legit, 3), "p_suspicious": round(p_susp, 3), "p_malicious": round(p_mal, 3), }, } except Exception as e: logger.error(f"URL analysis error: {e}") return { "risk_level": "Suspicious", "confidence": 0.5, "reasoning": f"URL analysis error: {e}", "user_message": "Could not fully analyze this URL. Treat with caution.", "domain": "unknown", "url_status": "error", } def generate_user_response(self, risk_level: str) -> str: responses = { "Safe": "This message appears safe.", "Suspicious": "Be cautious -- this message has suspicious elements.", "Scam": "WARNING: This appears to be a scam! Do not click links or share personal info.", } return responses.get(risk_level, "Unable to analyze.")