Sanjam19 commited on
Commit
c8f09a0
·
verified ·
1 Parent(s): 71ba907

Improving ML logic to reduce False Negatives.

Browse files
Files changed (1) hide show
  1. ml_utils.py +302 -1127
ml_utils.py CHANGED
@@ -1,1128 +1,303 @@
1
- # # ml_utils.py
2
-
3
- # import re
4
- # import logging
5
- # from typing import Dict, Optional
6
- # from urllib.parse import urlparse
7
-
8
- # from transformers import pipeline
9
- # from langdetect import detect, LangDetectError
10
-
11
- # logging.basicConfig(level=logging.INFO)
12
- # logger = logging.getLogger(__name__)
13
-
14
-
15
- # class ScamDetectionService:
16
- # def __init__(self):
17
- # logger.info("Loading zero-shot classification model...")
18
-
19
- # # Real HuggingFace zero-shot classifier
20
- # # facebook/bart-large-mnli is free, no fine-tuning needed, works out of the box
21
- # self.classifier = pipeline(
22
- # "zero-shot-classification",
23
- # model="facebook/bart-large-mnli"
24
- # )
25
-
26
- # self.scam_labels = ["scam or phishing", "suspicious", "safe"]
27
-
28
- # # URL shorteners — rule-based check on top of ML
29
- # self.suspicious_domains = [
30
- # 'bit.ly', 'tinyurl.com', 'short.link', 'tiny.cc', 'ow.ly'
31
- # ]
32
-
33
- # logger.info("Model loaded successfully.")
34
-
35
- # def detect_language(self, text: str) -> str:
36
- # """Detect language using langdetect library"""
37
- # try:
38
- # return detect(text)
39
- # except LangDetectError:
40
- # return 'en' # default to English if detection fails
41
-
42
- # def get_language_confidence(self, text: str) -> float:
43
- # """Rough confidence based on text length"""
44
- # word_count = len(text.split())
45
- # if word_count < 5:
46
- # return 0.6
47
- # elif word_count < 15:
48
- # return 0.8
49
- # return 0.9
50
-
51
- # def analyze_text_scam(self, text: str, language: str = None) -> Dict:
52
- # """Analyze text using zero-shot NLP classification"""
53
- # if not text or not text.strip():
54
- # return {
55
- # "risk_level": "Safe",
56
- # "confidence": 0.0,
57
- # "reasoning": "Empty text provided",
58
- # "detected_language": "unknown"
59
- # }
60
-
61
- # detected_language = language or self.detect_language(text)
62
-
63
- # try:
64
- # result = self.classifier(text, candidate_labels=self.scam_labels)
65
-
66
- # # result["labels"][0] is the top predicted label
67
- # top_label = result["labels"][0]
68
- # top_score = result["scores"][0]
69
-
70
- # # Map model output to our risk levels
71
- # if "scam" in top_label:
72
- # risk_level = "Scam"
73
- # elif "suspicious" in top_label:
74
- # risk_level = "Suspicious"
75
- # else:
76
- # risk_level = "Safe"
77
-
78
- # reasoning = f"Model classified as '{top_label}' with {round(top_score * 100, 1)}% confidence"
79
-
80
- # return {
81
- # "risk_level": risk_level,
82
- # "confidence": round(top_score, 4),
83
- # "reasoning": reasoning,
84
- # "detected_language": detected_language
85
- # }
86
-
87
- # except Exception as e:
88
- # logger.error(f"Classification failed: {e}")
89
- # return {
90
- # "risk_level": "Suspicious",
91
- # "confidence": 0.5,
92
- # "reasoning": f"Model error, defaulting to suspicious: {str(e)}",
93
- # "detected_language": detected_language
94
- # }
95
-
96
- # def analyze_url_scam(self, url: str, context: str = "") -> Dict:
97
- # """
98
- # Analyze URL using a combination of:
99
- # 1. Rule-based checks (IP, known patterns, shorteners)
100
- # 2. Zero-shot classification on the URL string + context
101
- # """
102
- # if not url:
103
- # return {
104
- # "risk_level": "Safe",
105
- # "confidence": 0.0,
106
- # "reasoning": "No URL provided",
107
- # "domain": "",
108
- # "url_status": "invalid"
109
- # }
110
-
111
- # try:
112
- # if not url.startswith(('http://', 'https://')):
113
- # url = 'http://' + url
114
-
115
- # parsed_url = urlparse(url)
116
- # domain = parsed_url.netloc.lower()
117
-
118
- # rule_risk = 0.0
119
- # rule_reasons = []
120
-
121
- # known_scam_patterns = [
122
- # "faceb00k", "paypa1", "amaz0n", "micros0ft",
123
- # "appleid-support", "login-secure", "claim-your-free",
124
- # "lottery", "techsupport", "quickloan"
125
- # ]
126
-
127
- # if any(pattern in domain for pattern in known_scam_patterns):
128
- # rule_risk += 0.7
129
- # rule_reasons.append("Matches known scam domain pattern")
130
-
131
- # if any(s in domain for s in self.suspicious_domains):
132
- # rule_risk += 0.6
133
- # rule_reasons.append("Uses URL shortening service")
134
-
135
- # if re.search(r'\d+\.\d+\.\d+\.\d+', domain):
136
- # rule_risk += 0.8
137
- # rule_reasons.append("Uses raw IP address instead of domain")
138
-
139
- # if len(url) > 100:
140
- # rule_risk += 0.2
141
- # rule_reasons.append("Unusually long URL")
142
-
143
- # suspicious_words = ['login', 'verify', 'update', 'confirm', 'secure']
144
- # if any(word in url.lower() for word in suspicious_words):
145
- # rule_risk += 0.3
146
- # rule_reasons.append("Contains suspicious keywords in URL")
147
-
148
- # # Also run zero-shot on the URL + context string for extra signal
149
- # analysis_text = f"URL: {url}"
150
- # if context:
151
- # analysis_text += f" Context: {context}"
152
-
153
- # ml_result = self.classifier(analysis_text, candidate_labels=self.scam_labels)
154
- # ml_top_label = ml_result["labels"][0]
155
- # ml_top_score = ml_result["scores"][0]
156
-
157
- # # Blend rule-based + ML score
158
- # if "scam" in ml_top_label:
159
- # ml_risk = ml_top_score
160
- # elif "suspicious" in ml_top_label:
161
- # ml_risk = ml_top_score * 0.5
162
- # else:
163
- # ml_risk = 0.0
164
-
165
- # final_score = min((rule_risk * 0.6) + (ml_risk * 0.4), 1.0)
166
-
167
- # if final_score >= 0.6:
168
- # risk_level = "Scam"
169
- # elif final_score >= 0.35:
170
- # risk_level = "Suspicious"
171
- # else:
172
- # risk_level = "Safe"
173
-
174
- # rule_summary = "; ".join(rule_reasons) if rule_reasons else "No rule-based flags"
175
- # reasoning = f"{rule_summary} | ML: '{ml_top_label}' ({round(ml_top_score * 100, 1)}%)"
176
-
177
- # return {
178
- # "risk_level": risk_level,
179
- # "confidence": round(final_score, 4),
180
- # "reasoning": reasoning,
181
- # "domain": domain,
182
- # "url_status": "analyzed"
183
- # }
184
-
185
- # except Exception as e:
186
- # return {
187
- # "risk_level": "Suspicious",
188
- # "confidence": 0.5,
189
- # "reasoning": f"URL analysis error: {str(e)}",
190
- # "domain": "unknown",
191
- # "url_status": "error"
192
- # }
193
-
194
- # def generate_user_response(self, risk_level: str) -> str:
195
- # responses = {
196
- # "Safe": "✅ This message appears safe.",
197
- # "Suspicious": "⚠️ Be cautious - this message has suspicious elements.",
198
- # "Scam": "🚨 WARNING: This appears to be a scam! Do not click links or share personal info."
199
- # }
200
- # return responses.get(risk_level, "Unable to analyze.")
201
-
202
-
203
- ##############################################################################################
204
- # ml_utils.py
205
-
206
- # import re
207
- # import logging
208
- # from typing import Dict, Optional
209
- # from urllib.parse import urlparse
210
-
211
- # from transformers import pipeline
212
- # from langdetect import detect
213
- # from langdetect.lang_detect_exception import LangDetectException
214
-
215
- # logging.basicConfig(level=logging.INFO)
216
- # logger = logging.getLogger(__name__)
217
-
218
-
219
- # class ScamDetectionService:
220
- # def __init__(self):
221
- # logger.info("Loading zero-shot classification model...")
222
-
223
- # # Real HuggingFace zero-shot classifier
224
- # # facebook/bart-large-mnli is free, no fine-tuning needed, works out of the box
225
- # self.classifier = pipeline(
226
- # "zero-shot-classification",
227
- # model="facebook/bart-large-mnli"
228
- # )
229
-
230
- # self.scam_labels = ["scam or phishing", "suspicious", "safe"]
231
-
232
- # # URL shorteners — rule-based check on top of ML
233
- # self.suspicious_domains = [
234
- # 'bit.ly', 'tinyurl.com', 'short.link', 'tiny.cc', 'ow.ly'
235
- # ]
236
-
237
- # logger.info("Model loaded successfully.")
238
-
239
- # def detect_language(self, text: str) -> str:
240
- # """Detect language using langdetect library"""
241
- # try:
242
- # return detect(text)
243
- # except LangDetectException:
244
- # return 'en' # default to English if detection fails
245
-
246
- # def get_language_confidence(self, text: str) -> float:
247
- # """Rough confidence based on text length"""
248
- # word_count = len(text.split())
249
- # if word_count < 5:
250
- # return 0.6
251
- # elif word_count < 15:
252
- # return 0.8
253
- # return 0.9
254
-
255
- # def analyze_text_scam(self, text: str, language: str = None) -> Dict:
256
- # """Analyze text using zero-shot NLP classification"""
257
- # if not text or not text.strip():
258
- # return {
259
- # "risk_level": "Safe",
260
- # "confidence": 0.0,
261
- # "reasoning": "Empty text provided",
262
- # "detected_language": "unknown"
263
- # }
264
-
265
- # detected_language = language or self.detect_language(text)
266
-
267
- # try:
268
- # result = self.classifier(text, candidate_labels=self.scam_labels)
269
-
270
- # # result["labels"][0] is the top predicted label
271
- # top_label = result["labels"][0]
272
- # top_score = result["scores"][0]
273
-
274
- # # Map model output to our risk levels
275
- # if "scam" in top_label:
276
- # risk_level = "Scam"
277
- # elif "suspicious" in top_label:
278
- # risk_level = "Suspicious"
279
- # else:
280
- # risk_level = "Safe"
281
-
282
- # reasoning = f"Model classified as '{top_label}' with {round(top_score * 100, 1)}% confidence"
283
-
284
- # return {
285
- # "risk_level": risk_level,
286
- # "confidence": round(top_score, 4),
287
- # "reasoning": reasoning,
288
- # "detected_language": detected_language
289
- # }
290
-
291
- # except Exception as e:
292
- # logger.error(f"Classification failed: {e}")
293
- # return {
294
- # "risk_level": "Suspicious",
295
- # "confidence": 0.5,
296
- # "reasoning": f"Model error, defaulting to suspicious: {str(e)}",
297
- # "detected_language": detected_language
298
- # }
299
-
300
- # def analyze_url_scam(self, url: str, context: str = "") -> Dict:
301
- # """
302
- # Analyze URL using a combination of:
303
- # 1. Rule-based checks (IP, known patterns, shorteners)
304
- # 2. Zero-shot classification on the URL string + context
305
- # """
306
- # if not url:
307
- # return {
308
- # "risk_level": "Safe",
309
- # "confidence": 0.0,
310
- # "reasoning": "No URL provided",
311
- # "domain": "",
312
- # "url_status": "invalid"
313
- # }
314
-
315
- # try:
316
- # if not url.startswith(('http://', 'https://')):
317
- # url = 'http://' + url
318
-
319
- # parsed_url = urlparse(url)
320
- # domain = parsed_url.netloc.lower()
321
-
322
- # rule_risk = 0.0
323
- # rule_reasons = []
324
-
325
- # known_scam_patterns = [
326
- # "faceb00k", "paypa1", "amaz0n", "micros0ft",
327
- # "appleid-support", "login-secure", "claim-your-free",
328
- # "lottery", "techsupport", "quickloan"
329
- # ]
330
-
331
- # if any(pattern in domain for pattern in known_scam_patterns):
332
- # rule_risk += 0.7
333
- # rule_reasons.append("Matches known scam domain pattern")
334
-
335
- # if any(s in domain for s in self.suspicious_domains):
336
- # rule_risk += 0.6
337
- # rule_reasons.append("Uses URL shortening service")
338
-
339
- # if re.search(r'\d+\.\d+\.\d+\.\d+', domain):
340
- # rule_risk += 0.8
341
- # rule_reasons.append("Uses raw IP address instead of domain")
342
-
343
- # if len(url) > 100:
344
- # rule_risk += 0.2
345
- # rule_reasons.append("Unusually long URL")
346
-
347
- # suspicious_words = ['login', 'verify', 'update', 'confirm', 'secure']
348
- # if any(word in url.lower() for word in suspicious_words):
349
- # rule_risk += 0.3
350
- # rule_reasons.append("Contains suspicious keywords in URL")
351
-
352
- # # Also run zero-shot on the URL + context string for extra signal
353
- # analysis_text = f"URL: {url}"
354
- # if context:
355
- # analysis_text += f" Context: {context}"
356
-
357
- # ml_result = self.classifier(analysis_text, candidate_labels=self.scam_labels)
358
- # ml_top_label = ml_result["labels"][0]
359
- # ml_top_score = ml_result["scores"][0]
360
-
361
- # # Blend rule-based + ML score
362
- # if "scam" in ml_top_label:
363
- # ml_risk = ml_top_score
364
- # elif "suspicious" in ml_top_label:
365
- # ml_risk = ml_top_score * 0.5
366
- # else:
367
- # ml_risk = 0.0
368
-
369
- # final_score = min((rule_risk * 0.6) + (ml_risk * 0.4), 1.0)
370
-
371
- # if final_score >= 0.6:
372
- # risk_level = "Scam"
373
- # elif final_score >= 0.35:
374
- # risk_level = "Suspicious"
375
- # else:
376
- # risk_level = "Safe"
377
-
378
- # rule_summary = "; ".join(rule_reasons) if rule_reasons else "No rule-based flags"
379
- # reasoning = f"{rule_summary} | ML: '{ml_top_label}' ({round(ml_top_score * 100, 1)}%)"
380
-
381
- # return {
382
- # "risk_level": risk_level,
383
- # "confidence": round(final_score, 4),
384
- # "reasoning": reasoning,
385
- # "domain": domain,
386
- # "url_status": "analyzed"
387
- # }
388
-
389
- # except Exception as e:
390
- # return {
391
- # "risk_level": "Suspicious",
392
- # "confidence": 0.5,
393
- # "reasoning": f"URL analysis error: {str(e)}",
394
- # "domain": "unknown",
395
- # "url_status": "error"
396
- # }
397
-
398
- # def generate_user_response(self, risk_level: str) -> str:
399
- # responses = {
400
- # "Safe": "✅ This message appears safe.",
401
- # "Suspicious": "⚠️ Be cautious - this message has suspicious elements.",
402
- # "Scam": "🚨 WARNING: This appears to be a scam! Do not click links or share personal info."
403
- # }
404
- # return responses.get(risk_level, "Unable to analyze.")
405
-
406
- #################################################################################################
407
-
408
- # ml_utils.py - FIXED VERSION
409
-
410
- # import re
411
- # import logging
412
- # from typing import Dict
413
- # from urllib.parse import urlparse
414
-
415
- # # Use a REAL spam detection model
416
- # from transformers import pipeline
417
-
418
- # logging.basicConfig(level=logging.INFO)
419
- # logger = logging.getLogger(__name__)
420
-
421
-
422
- # class ScamDetectionService:
423
- # def __init__(self):
424
- # logger.info("Loading spam detection model...")
425
-
426
- # # Option A: mrm8488/bert-tiny-finetuned-sms-spam-detection
427
- # # 90%+ accuracy on SMS spam, only 17MB
428
- # self.classifier = pipeline(
429
- # "text-classification",
430
- # model="mrm8488/bert-tiny-finetuned-sms-spam-detection"
431
- # )
432
-
433
- # # URL shortener blacklist
434
- # self.suspicious_domains = [
435
- # 'bit.ly', 'tinyurl.com', 'short.link', 'tiny.cc', 'ow.ly'
436
- # ]
437
-
438
- # logger.info("Model loaded successfully.")
439
-
440
- # def detect_language(self, text: str) -> str:
441
- # """Simple language detection based on character set"""
442
- # # Lightweight approach - no need for langdetect
443
- # if re.search(r'[\u0900-\u097F]', text): # Devanagari
444
- # return 'hi'
445
- # elif re.search(r'[\u0980-\u09FF]', text): # Bengali/Odia
446
- # return 'or'
447
- # else:
448
- # return 'en'
449
-
450
- # def analyze_text_scam(self, text: str, language: str = None) -> Dict:
451
- # """Analyze text using fine-tuned spam detection model"""
452
- # if not text or not text.strip():
453
- # return {
454
- # "risk_level": "Safe",
455
- # "confidence": 0.0,
456
- # "reasoning": "Empty text provided",
457
- # "detected_language": "unknown"
458
- # }
459
-
460
- # detected_language = language or self.detect_language(text)
461
-
462
- # try:
463
- # # Get prediction
464
- # result = self.classifier(text)[0]
465
-
466
- # # Model returns: {'label': 'SPAM', 'score': 0.99}
467
- # label = result['label'].upper()
468
- # score = result['score']
469
-
470
- # # Map to your risk levels
471
- # if label == 'SPAM' and score > 0.7:
472
- # risk_level = "Scam"
473
- # confidence = score
474
- # elif label == 'SPAM' and score > 0.4:
475
- # risk_level = "Suspicious"
476
- # confidence = score * 0.8
477
- # else:
478
- # risk_level = "Safe"
479
- # confidence = 1 - score
480
-
481
- # # Add rule-based boost for common scam patterns
482
- # scam_keywords = [
483
- # 'congratulations', 'winner', 'claim', 'urgent', 'verify',
484
- # 'account suspended', 'prize', 'free money', 'click here',
485
- # 'act now', 'limited time', 'bank details'
486
- # ]
487
-
488
- # keyword_count = sum(1 for kw in scam_keywords if kw.lower() in text.lower())
489
- # if keyword_count >= 2:
490
- # # Boost confidence if multiple scam keywords present
491
- # confidence = min(confidence + 0.15, 0.99)
492
- # if risk_level == "Safe":
493
- # risk_level = "Suspicious"
494
-
495
- # reasoning = f"Spam classifier: {label} ({round(score * 100, 1)}%)"
496
- # if keyword_count > 0:
497
- # reasoning += f" + {keyword_count} scam keywords detected"
498
-
499
- # return {
500
- # "risk_level": risk_level,
501
- # "confidence": round(confidence, 4),
502
- # "reasoning": reasoning,
503
- # "detected_language": detected_language
504
- # }
505
-
506
- # except Exception as e:
507
- # logger.error(f"Classification failed: {e}")
508
- # return {
509
- # "risk_level": "Suspicious",
510
- # "confidence": 0.5,
511
- # "reasoning": f"Model error: {str(e)}",
512
- # "detected_language": detected_language
513
- # }
514
-
515
- # def analyze_url_scam(self, url: str, context: str = "") -> Dict:
516
- # """
517
- # Analyze URL using rule-based checks + ML on context
518
- # """
519
- # if not url:
520
- # return {
521
- # "risk_level": "Safe",
522
- # "confidence": 0.0,
523
- # "reasoning": "No URL provided",
524
- # "domain": "",
525
- # "url_status": "invalid"
526
- # }
527
-
528
- # try:
529
- # if not url.startswith(('http://', 'https://')):
530
- # url = 'http://' + url
531
-
532
- # parsed_url = urlparse(url)
533
- # domain = parsed_url.netloc.lower()
534
-
535
- # rule_risk = 0.0
536
- # rule_reasons = []
537
-
538
- # # Known scam domain patterns
539
- # scam_patterns = [
540
- # "faceb00k", "paypa1", "amaz0n", "micros0ft",
541
- # "appleid-support", "login-secure", "claim-your-free",
542
- # "lottery", "techsupport", "quickloan", "verify-account"
543
- # ]
544
-
545
- # if any(pattern in domain for pattern in scam_patterns):
546
- # rule_risk += 0.8
547
- # rule_reasons.append("Domain mimics trusted brand")
548
-
549
- # if any(s in domain for s in self.suspicious_domains):
550
- # rule_risk += 0.6
551
- # rule_reasons.append("URL shortening service")
552
-
553
- # # IP address instead of domain
554
- # if re.search(r'\d+\.\d+\.\d+\.\d+', domain):
555
- # rule_risk += 0.9
556
- # rule_reasons.append("Uses raw IP address")
557
-
558
- # # Suspicious TLDs
559
- # suspicious_tlds = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz']
560
- # if any(domain.endswith(tld) for tld in suspicious_tlds):
561
- # rule_risk += 0.5
562
- # rule_reasons.append("Suspicious top-level domain")
563
-
564
- # # Long URL (often phishing)
565
- # if len(url) > 100:
566
- # rule_risk += 0.3
567
- # rule_reasons.append("Unusually long URL")
568
-
569
- # # Security-related words in URL
570
- # security_words = ['login', 'verify', 'update', 'confirm', 'secure', 'account']
571
- # if any(word in url.lower() for word in security_words):
572
- # rule_risk += 0.4
573
- # rule_reasons.append("Contains security-related keywords")
574
-
575
- # # If context provided, analyze it for spam
576
- # ml_risk = 0.0
577
- # if context:
578
- # context_result = self.analyze_text_scam(context)
579
- # if context_result['risk_level'] == 'Scam':
580
- # ml_risk = context_result['confidence']
581
- # elif context_result['risk_level'] == 'Suspicious':
582
- # ml_risk = context_result['confidence'] * 0.5
583
-
584
- # # Combine rule-based + ML
585
- # final_score = min((rule_risk * 0.7) + (ml_risk * 0.3), 1.0)
586
-
587
- # if final_score >= 0.7:
588
- # risk_level = "Scam"
589
- # elif final_score >= 0.4:
590
- # risk_level = "Suspicious"
591
- # else:
592
- # risk_level = "Safe"
593
-
594
- # rule_summary = "; ".join(rule_reasons) if rule_reasons else "No suspicious patterns"
595
- # reasoning = f"{rule_summary}"
596
- # if context:
597
- # reasoning += f" | Context analysis: {context_result['risk_level']}"
598
-
599
- # return {
600
- # "risk_level": risk_level,
601
- # "confidence": round(final_score, 4),
602
- # "reasoning": reasoning,
603
- # "domain": domain,
604
- # "url_status": "analyzed"
605
- # }
606
-
607
- # except Exception as e:
608
- # return {
609
- # "risk_level": "Suspicious",
610
- # "confidence": 0.5,
611
- # "reasoning": f"URL analysis error: {str(e)}",
612
- # "domain": "unknown",
613
- # "url_status": "error"
614
- # }
615
-
616
- # def generate_user_response(self, risk_level: str) -> str:
617
- # responses = {
618
- # "Safe": "✅ This message appears safe.",
619
- # "Suspicious": "⚠️ Be cautious - this message has suspicious elements.",
620
- # "Scam": "🚨 WARNING: This appears to be a scam! Do not click links or share personal info."
621
- # }
622
- # return responses.get(risk_level, "Unable to analyze.")
623
- ###################################################################################################
624
-
625
- # ml_utils.py - PROPERLY CALIBRATED VERSION
626
-
627
- # import re
628
- # import logging
629
- # from typing import Dict
630
- # from urllib.parse import urlparse
631
-
632
- # from transformers import pipeline
633
-
634
- # logging.basicConfig(level=logging.INFO)
635
- # logger = logging.getLogger(__name__)
636
-
637
-
638
- # class ScamDetectionService:
639
- # def __init__(self):
640
- # logger.info("Loading spam detection model...")
641
-
642
- # self.classifier = pipeline(
643
- # "text-classification",
644
- # model="mrm8488/bert-tiny-finetuned-sms-spam-detection"
645
- # )
646
-
647
- # self.suspicious_domains = [
648
- # 'bit.ly', 'tinyurl.com', 'short.link', 'tiny.cc', 'ow.ly'
649
- # ]
650
-
651
- # logger.info("Model loaded successfully.")
652
-
653
- # def detect_language(self, text: str) -> str:
654
- # """Simple language detection based on character set"""
655
- # if re.search(r'[\u0900-\u097F]', text):
656
- # return 'hi'
657
- # elif re.search(r'[\u0980-\u09FF]', text):
658
- # return 'or'
659
- # else:
660
- # return 'en'
661
-
662
- # def analyze_text_scam(self, text: str, language: str = None) -> Dict:
663
- # """Analyze text using fine-tuned spam detection model"""
664
- # if not text or not text.strip():
665
- # return {
666
- # "risk_level": "Safe",
667
- # "confidence": 0.0,
668
- # "reasoning": "Empty text provided",
669
- # "detected_language": "unknown"
670
- # }
671
-
672
- # detected_language = language or self.detect_language(text)
673
-
674
- # try:
675
- # result = self.classifier(text)[0]
676
-
677
- # label = result['label'].upper()
678
- # score = result['score']
679
-
680
- # # FIXED: Proper threshold mapping
681
- # if label == 'SPAM':
682
- # # Model says SPAM
683
- # if score >= 0.5: # Changed from 0.7 - trust the model more
684
- # risk_level = "Scam"
685
- # confidence = score
686
- # else:
687
- # risk_level = "Suspicious"
688
- # confidence = score * 0.9
689
- # else:
690
- # # Model says HAM (not spam)
691
- # if score >= 0.8: # Very confident it's safe
692
- # risk_level = "Safe"
693
- # confidence = score
694
- # elif score >= 0.5: # Somewhat confident it's safe
695
- # risk_level = "Safe"
696
- # confidence = score * 0.8
697
- # else:
698
- # # Model unsure, check for keywords
699
- # risk_level = "Suspicious"
700
- # confidence = 1 - score
701
-
702
- # # Rule-based keyword boost
703
- # scam_keywords = [
704
- # 'congratulations', 'winner', 'won', 'claim', 'urgent',
705
- # 'verify', 'suspended', 'prize', 'free money', 'click here',
706
- # 'act now', 'limited time', 'expire', 'confirm', 'update',
707
- # 'dear customer', 'bank details', 'credit card', 'password'
708
- # ]
709
-
710
- # text_lower = text.lower()
711
- # keyword_matches = [kw for kw in scam_keywords if kw in text_lower]
712
- # keyword_count = len(keyword_matches)
713
-
714
- # # Boost scam score if keywords present
715
- # if keyword_count >= 3:
716
- # if risk_level == "Safe":
717
- # risk_level = "Scam"
718
- # confidence = min(confidence + 0.3, 0.95)
719
- # elif risk_level == "Suspicious":
720
- # risk_level = "Scam"
721
- # confidence = min(confidence + 0.2, 0.90)
722
- # elif keyword_count >= 2:
723
- # if risk_level == "Safe":
724
- # risk_level = "Suspicious"
725
- # confidence = min(confidence + 0.2, 0.85)
726
-
727
- # # Check for common spam patterns
728
- # spam_patterns = [
729
- # r'\b(click here|click now)\b',
730
- # r'\b(act now|hurry|urgent)\b',
731
- # r'\b(won|winner|prize)\b',
732
- # r'\b(free money|cash prize)\b',
733
- # r'\b(verify|confirm|update).*(account|password|details)\b',
734
- # r'\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b', # Credit card pattern
735
- # ]
736
-
737
- # pattern_matches = sum(1 for pattern in spam_patterns if re.search(pattern, text_lower))
738
-
739
- # if pattern_matches >= 2 and risk_level != "Scam":
740
- # risk_level = "Scam"
741
- # confidence = min(confidence + 0.25, 0.92)
742
-
743
- # reasoning = f"ML: {label} ({round(score * 100, 1)}%)"
744
- # if keyword_count > 0:
745
- # reasoning += f" | {keyword_count} scam keywords: {', '.join(keyword_matches[:3])}"
746
- # if pattern_matches > 0:
747
- # reasoning += f" | {pattern_matches} suspicious patterns"
748
-
749
- # return {
750
- # "risk_level": risk_level,
751
- # "confidence": round(confidence, 4),
752
- # "reasoning": reasoning,
753
- # "detected_language": detected_language
754
- # }
755
-
756
- # except Exception as e:
757
- # logger.error(f"Classification failed: {e}")
758
- # return {
759
- # "risk_level": "Suspicious",
760
- # "confidence": 0.5,
761
- # "reasoning": f"Model error: {str(e)}",
762
- # "detected_language": detected_language
763
- # }
764
-
765
- # def analyze_url_scam(self, url: str, context: str = "") -> Dict:
766
- # """Analyze URL using rule-based checks + ML on context"""
767
- # if not url:
768
- # return {
769
- # "risk_level": "Safe",
770
- # "confidence": 0.0,
771
- # "reasoning": "No URL provided",
772
- # "domain": "",
773
- # "url_status": "invalid"
774
- # }
775
-
776
- # try:
777
- # if not url.startswith(('http://', 'https://')):
778
- # url = 'http://' + url
779
-
780
- # parsed_url = urlparse(url)
781
- # domain = parsed_url.netloc.lower()
782
-
783
- # rule_risk = 0.0
784
- # rule_reasons = []
785
-
786
- # # Known scam patterns
787
- # scam_patterns = [
788
- # "faceb00k", "paypa1", "amaz0n", "micros0ft", "g00gle",
789
- # "appleid", "login-secure", "claim-your", "verify-account",
790
- # "lottery", "techsupport", "quickloan", "account-update"
791
- # ]
792
-
793
- # if any(pattern in domain for pattern in scam_patterns):
794
- # rule_risk += 0.8
795
- # rule_reasons.append("Domain mimics trusted brand")
796
-
797
- # if any(s in domain for s in self.suspicious_domains):
798
- # rule_risk += 0.6
799
- # rule_reasons.append("URL shortening service")
800
-
801
- # if re.search(r'\d+\.\d+\.\d+\.\d+', domain):
802
- # rule_risk += 0.9
803
- # rule_reasons.append("Uses raw IP address")
804
-
805
- # suspicious_tlds = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top']
806
- # if any(domain.endswith(tld) for tld in suspicious_tlds):
807
- # rule_risk += 0.7
808
- # rule_reasons.append("Suspicious top-level domain")
809
-
810
- # if len(url) > 100:
811
- # rule_risk += 0.3
812
- # rule_reasons.append("Unusually long URL")
813
-
814
- # security_words = ['login', 'verify', 'update', 'confirm', 'secure', 'account']
815
- # if any(word in url.lower() for word in security_words):
816
- # rule_risk += 0.4
817
- # rule_reasons.append("Security-related keywords in URL")
818
-
819
- # # Analyze context if provided
820
- # ml_risk = 0.0
821
- # if context:
822
- # context_result = self.analyze_text_scam(context)
823
- # if context_result['risk_level'] == 'Scam':
824
- # ml_risk = context_result['confidence']
825
- # elif context_result['risk_level'] == 'Suspicious':
826
- # ml_risk = context_result['confidence'] * 0.6
827
-
828
- # final_score = min((rule_risk * 0.65) + (ml_risk * 0.35), 1.0)
829
-
830
- # if final_score >= 0.65:
831
- # risk_level = "Scam"
832
- # elif final_score >= 0.35:
833
- # risk_level = "Suspicious"
834
- # else:
835
- # risk_level = "Safe"
836
-
837
- # rule_summary = "; ".join(rule_reasons) if rule_reasons else "No suspicious patterns"
838
- # reasoning = f"{rule_summary}"
839
- # if context:
840
- # reasoning += f" | Message context: {context_result['risk_level']}"
841
-
842
- # return {
843
- # "risk_level": risk_level,
844
- # "confidence": round(final_score, 4),
845
- # "reasoning": reasoning,
846
- # "domain": domain,
847
- # "url_status": "analyzed"
848
- # }
849
-
850
- # except Exception as e:
851
- # return {
852
- # "risk_level": "Suspicious",
853
- # "confidence": 0.5,
854
- # "reasoning": f"URL analysis error: {str(e)}",
855
- # "domain": "unknown",
856
- # "url_status": "error"
857
- # }
858
-
859
- # def generate_user_response(self, risk_level: str) -> str:
860
- # responses = {
861
- # "Safe": "✅ This message appears safe.",
862
- # "Suspicious": "⚠️ Be cautious - this message has suspicious elements.",
863
- # "Scam": "🚨 WARNING: This appears to be a scam! Do not click links or share personal info."
864
- # }
865
- # return responses.get(risk_level, "Unable to analyze.")
866
-
867
- ##################################################################################################
868
-
869
- # ml_utils.py - TF-IDF + Logistic Regression (PROVEN APPROACH)
870
-
871
- import re
872
- import pickle
873
- import logging
874
- from typing import Dict
875
- from urllib.parse import urlparse
876
- from pathlib import Path
877
-
878
- from sklearn.feature_extraction.text import TfidfVectorizer
879
- from sklearn.linear_model import LogisticRegression
880
- from sklearn.pipeline import Pipeline
881
- import pandas as pd
882
-
883
- logging.basicConfig(level=logging.INFO)
884
- logger = logging.getLogger(__name__)
885
-
886
-
887
- class ScamDetectionService:
888
- def __init__(self):
889
- logger.info("Loading spam detection model...")
890
-
891
- model_path = Path("spam_model.pkl")
892
-
893
- if model_path.exists():
894
- with open(model_path, 'rb') as f:
895
- self.model = pickle.load(f)
896
- logger.info("Model loaded from disk.")
897
- else:
898
- logger.info("Training new model on SMS Spam dataset...")
899
- self.train_model()
900
-
901
- self.suspicious_domains = [
902
- 'bit.ly', 'tinyurl.com', 'short.link', 'tiny.cc', 'ow.ly'
903
- ]
904
-
905
- logger.info("Model ready.")
906
-
907
- def train_model(self):
908
- """Train TF-IDF + Logistic Regression on spam dataset"""
909
- try:
910
- df = pd.read_csv("spam.csv", encoding='latin-1')[['v1', 'v2']]
911
- df.columns = ['label', 'text']
912
-
913
- # Convert labels: ham -> 0, spam -> 1
914
- df['label'] = df['label'].map({'ham': 0, 'spam': 1})
915
-
916
- # Build pipeline
917
- self.model = Pipeline([
918
- ('tfidf', TfidfVectorizer(
919
- max_features=3000,
920
- ngram_range=(1, 2), # Unigrams + bigrams
921
- stop_words='english',
922
- min_df=2,
923
- sublinear_tf=True
924
- )),
925
- ('clf', LogisticRegression(
926
- max_iter=1000,
927
- C=1.0,
928
- class_weight='balanced' # Handle class imbalance
929
- ))
930
- ])
931
-
932
- # Train on full dataset
933
- logger.info(f"Training on {len(df)} messages...")
934
- self.model.fit(df['text'], df['label'])
935
-
936
- # Save model
937
- with open("spam_model.pkl", 'wb') as f:
938
- pickle.dump(self.model, f)
939
-
940
- logger.info("Model trained and saved to spam_model.pkl")
941
-
942
- except FileNotFoundError:
943
- logger.error("spam.csv not found! Please download the SMS Spam Collection dataset.")
944
- raise
945
- except Exception as e:
946
- logger.error(f"Training failed: {e}")
947
- raise
948
-
949
- def detect_language(self, text: str) -> str:
950
- """Simple language detection based on character set"""
951
- if re.search(r'[\u0900-\u097F]', text): # Devanagari
952
- return 'hi'
953
- elif re.search(r'[\u0980-\u09FF]', text): # Bengali/Odia
954
- return 'or'
955
- else:
956
- return 'en'
957
-
958
- def analyze_text_scam(self, text: str, language: str = None) -> Dict:
959
- """Analyze text using trained spam classifier"""
960
- if not text or not text.strip():
961
- return {
962
- "risk_level": "Safe",
963
- "confidence": 0.0,
964
- "reasoning": "Empty text provided",
965
- "detected_language": "unknown"
966
- }
967
-
968
- detected_language = language or self.detect_language(text)
969
-
970
- try:
971
- # Get probability scores [ham_prob, spam_prob]
972
- proba = self.model.predict_proba([text])[0]
973
- spam_prob = proba[1] # Probability of spam class
974
-
975
- # Map to risk levels with adjusted thresholds
976
- if spam_prob >= 0.65:
977
- risk_level = "Scam"
978
- confidence = spam_prob
979
- elif spam_prob >= 0.35:
980
- risk_level = "Suspicious"
981
- confidence = spam_prob
982
- else:
983
- risk_level = "Safe"
984
- confidence = 1 - spam_prob
985
-
986
- # Additional rule-based checks for edge cases
987
- scam_keywords = [
988
- 'congratulations', 'winner', 'won', 'claim', 'urgent',
989
- 'verify', 'suspended', 'prize', 'free money', 'click here',
990
- 'act now', 'limited time', 'expire', 'confirm'
991
- ]
992
-
993
- text_lower = text.lower()
994
- keyword_count = sum(1 for kw in scam_keywords if kw in text_lower)
995
-
996
- # Boost if multiple scam keywords present
997
- if keyword_count >= 3 and risk_level == "Safe":
998
- risk_level = "Suspicious"
999
- confidence = max(confidence, 0.6)
1000
-
1001
- reasoning = f"Spam probability: {round(spam_prob * 100, 1)}%"
1002
- if keyword_count > 0:
1003
- reasoning += f" ({keyword_count} scam keywords detected)"
1004
-
1005
- return {
1006
- "risk_level": risk_level,
1007
- "confidence": round(confidence, 4),
1008
- "reasoning": reasoning,
1009
- "detected_language": detected_language
1010
- }
1011
-
1012
- except Exception as e:
1013
- logger.error(f"Classification failed: {e}")
1014
- return {
1015
- "risk_level": "Suspicious",
1016
- "confidence": 0.5,
1017
- "reasoning": f"Model error: {str(e)}",
1018
- "detected_language": detected_language
1019
- }
1020
-
1021
- def analyze_url_scam(self, url: str, context: str = "") -> Dict:
1022
- """
1023
- Analyze URL using rule-based checks + ML on context
1024
- """
1025
- if not url:
1026
- return {
1027
- "risk_level": "Safe",
1028
- "confidence": 0.0,
1029
- "reasoning": "No URL provided",
1030
- "domain": "",
1031
- "url_status": "invalid"
1032
- }
1033
-
1034
- try:
1035
- if not url.startswith(('http://', 'https://')):
1036
- url = 'http://' + url
1037
-
1038
- parsed_url = urlparse(url)
1039
- domain = parsed_url.netloc.lower()
1040
-
1041
- rule_risk = 0.0
1042
- rule_reasons = []
1043
-
1044
- # Known scam domain patterns
1045
- scam_patterns = [
1046
- "faceb00k", "paypa1", "amaz0n", "micros0ft", "g00gle",
1047
- "appleid", "login-secure", "claim-your", "verify-account",
1048
- "lottery", "techsupport", "quickloan", "account-update"
1049
- ]
1050
-
1051
- if any(pattern in domain for pattern in scam_patterns):
1052
- rule_risk += 0.8
1053
- rule_reasons.append("Domain mimics trusted brand")
1054
-
1055
- if any(s in domain for s in self.suspicious_domains):
1056
- rule_risk += 0.6
1057
- rule_reasons.append("URL shortening service")
1058
-
1059
- # IP address instead of domain
1060
- if re.search(r'\d+\.\d+\.\d+\.\d+', domain):
1061
- rule_risk += 0.9
1062
- rule_reasons.append("Uses raw IP address")
1063
-
1064
- # Suspicious TLDs
1065
- suspicious_tlds = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top']
1066
- if any(domain.endswith(tld) for tld in suspicious_tlds):
1067
- rule_risk += 0.7
1068
- rule_reasons.append("Suspicious top-level domain")
1069
-
1070
- # Long URL (often phishing)
1071
- if len(url) > 100:
1072
- rule_risk += 0.3
1073
- rule_reasons.append("Unusually long URL")
1074
-
1075
- # Security-related words in URL
1076
- security_words = ['login', 'verify', 'update', 'confirm', 'secure', 'account']
1077
- if any(word in url.lower() for word in security_words):
1078
- rule_risk += 0.4
1079
- rule_reasons.append("Security-related keywords in URL")
1080
-
1081
- # Analyze context if provided
1082
- ml_risk = 0.0
1083
- if context:
1084
- context_result = self.analyze_text_scam(context)
1085
- if context_result['risk_level'] == 'Scam':
1086
- ml_risk = context_result['confidence']
1087
- elif context_result['risk_level'] == 'Suspicious':
1088
- ml_risk = context_result['confidence'] * 0.6
1089
-
1090
- # Combine rule-based + ML
1091
- final_score = min((rule_risk * 0.65) + (ml_risk * 0.35), 1.0)
1092
-
1093
- if final_score >= 0.65:
1094
- risk_level = "Scam"
1095
- elif final_score >= 0.35:
1096
- risk_level = "Suspicious"
1097
- else:
1098
- risk_level = "Safe"
1099
-
1100
- rule_summary = "; ".join(rule_reasons) if rule_reasons else "No suspicious patterns"
1101
- reasoning = f"{rule_summary}"
1102
- if context:
1103
- reasoning += f" | Message context: {context_result['risk_level']}"
1104
-
1105
- return {
1106
- "risk_level": risk_level,
1107
- "confidence": round(final_score, 4),
1108
- "reasoning": reasoning,
1109
- "domain": domain,
1110
- "url_status": "analyzed"
1111
- }
1112
-
1113
- except Exception as e:
1114
- return {
1115
- "risk_level": "Suspicious",
1116
- "confidence": 0.5,
1117
- "reasoning": f"URL analysis error: {str(e)}",
1118
- "domain": "unknown",
1119
- "url_status": "error"
1120
- }
1121
-
1122
- def generate_user_response(self, risk_level: str) -> str:
1123
- responses = {
1124
- "Safe": "✅ This message appears safe.",
1125
- "Suspicious": "⚠️ Be cautious - this message has suspicious elements.",
1126
- "Scam": "🚨 WARNING: This appears to be a scam! Do not click links or share personal info."
1127
- }
1128
  return responses.get(risk_level, "Unable to analyze.")
 
1
+ # ml_utils.py - Conservative thresholds, low false negatives
2
+
3
+ import re
4
+ import pickle
5
+ import logging
6
+ from typing import Dict
7
+ from urllib.parse import urlparse
8
+ from pathlib import Path
9
+
10
+ from sklearn.feature_extraction.text import TfidfVectorizer
11
+ from sklearn.linear_model import LogisticRegression
12
+ from sklearn.pipeline import Pipeline
13
+ import pandas as pd
14
+
15
+ logging.basicConfig(level=logging.INFO)
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ScamDetectionService:
20
+ def __init__(self):
21
+ logger.info("Loading spam detection model...")
22
+
23
+ model_path = Path("spam_model.pkl")
24
+
25
+ if model_path.exists():
26
+ with open(model_path, 'rb') as f:
27
+ self.model = pickle.load(f)
28
+ logger.info("Model loaded from disk.")
29
+ else:
30
+ logger.info("Training new model on SMS Spam dataset...")
31
+ self.train_model()
32
+
33
+ self.suspicious_shorteners = [
34
+ 'bit.ly', 'tinyurl.com', 'short.link', 'tiny.cc', 'ow.ly',
35
+ 'goo.gl', 't.co', 'rb.gy', 'is.gd', 'v.gd', 'cutt.ly'
36
+ ]
37
+
38
+ # Social engineering patterns the SMS dataset misses.
39
+ # These cover bank/govt impersonation, OTP fraud, KYC scams — common in India.
40
+ self.social_engineering_patterns = [
41
+ # OTP / credential harvesting
42
+ (r'\botp\b', 0.5, "OTP request"),
43
+ (r'\bshare.{0,20}otp\b', 0.85, "Requesting OTP"),
44
+ (r'\bconfirm.{0,20}(otp|pin|password)\b', 0.8, "Requesting credentials"),
45
+ (r'\b(last\s*4|last\s*four).{0,20}(digit|card)\b', 0.8, "Requesting card digits"),
46
+ (r'\b(card\s*number|cvv|expiry)\b', 0.75, "Requesting card details"),
47
+
48
+ # Bank / govt impersonation
49
+ (r'\b(bank|rbi|sbi|hdfc|icici|axis|kotak).{0,30}(suspend|block|close|deactivat)\b', 0.85, "Bank account threat"),
50
+ (r'\b(fraud\s*prevention|fraud\s*team)\b', 0.6, "Fraud team impersonation"),
51
+ (r'\b(verify|verif).{0,20}(account|identity|detail)\b', 0.65, "Account verification request"),
52
+ (r'\b(kyc|know\s*your\s*customer).{0,20}(update|expire|pending|complet)\b', 0.85, "KYC scam"),
53
+ (r'\baadhaar.{0,30}(link|update|verify|expire)\b', 0.8, "Aadhaar scam"),
54
+
55
+ # Urgency / pressure tactics
56
+ (r'\b(account|service).{0,20}(suspend|block|terminat|deactivat)\b', 0.7, "Account suspension threat"),
57
+ (r'\b(immediate|immediately|urgent|urgently).{0,30}(action|call|contact|verify)\b', 0.7, "Urgency pressure"),
58
+ (r'\bwithin\s*(24|48|72)\s*hours?\b', 0.65, "Time pressure"),
59
+ (r'\b(call|contact).{0,20}(helpline|support|team|number).{0,20}\d{10}\b', 0.7, "Fake helpline"),
60
+
61
+ # Prize / lottery
62
+ (r'\b(won|win|winner|winning).{0,30}(prize|lottery|lucky|reward|cash|gift)\b', 0.85, "Lottery/prize scam"),
63
+ (r'\bcongratulations.{0,50}(won|selected|chosen|winner)\b', 0.9, "Congratulations scam"),
64
+ (r'\bclaim.{0,20}(prize|reward|cash|gift|money)\b', 0.8, "Claim prize"),
65
+
66
+ # Job / investment
67
+ (r'\b(work\s*from\s*home|earn.{0,10}per\s*day|daily\s*earning)\b', 0.75, "Fake job offer"),
68
+ (r'\b(invest.{0,20}(return|profit|earning)).{0,30}(guaranteed|assured)\b', 0.8, "Investment scam"),
69
+ (r'\bdouble.{0,15}(money|investment|amount)\b', 0.85, "Investment doubling scam"),
70
+
71
+ # Generic phishing
72
+ (r'\bclick.{0,20}(link|here|below).{0,20}(verify|confirm|update|claim)\b', 0.75, "Phishing link"),
73
+ (r'\b(update|confirm).{0,20}(personal|bank|card|account)\s*(detail|info|data)\b', 0.8, "Data harvesting"),
74
+ ]
75
+
76
+ logger.info("Model ready.")
77
+
78
+ def train_model(self):
79
+ """Train TF-IDF + Logistic Regression on spam dataset"""
80
+ try:
81
+ df = pd.read_csv("spam.csv", encoding='latin-1')[['v1', 'v2']]
82
+ df.columns = ['label', 'text']
83
+ df['label'] = df['label'].map({'ham': 0, 'spam': 1})
84
+
85
+ self.model = Pipeline([
86
+ ('tfidf', TfidfVectorizer(
87
+ max_features=3000,
88
+ ngram_range=(1, 2),
89
+ stop_words='english',
90
+ min_df=2,
91
+ sublinear_tf=True
92
+ )),
93
+ ('clf', LogisticRegression(
94
+ max_iter=1000,
95
+ C=1.0,
96
+ class_weight='balanced'
97
+ ))
98
+ ])
99
+
100
+ logger.info(f"Training on {len(df)} messages...")
101
+ self.model.fit(df['text'], df['label'])
102
+
103
+ with open("spam_model.pkl", 'wb') as f:
104
+ pickle.dump(self.model, f)
105
+
106
+ logger.info("Model trained and saved.")
107
+
108
+ except FileNotFoundError:
109
+ logger.error("spam.csv not found!")
110
+ raise
111
+ except Exception as e:
112
+ logger.error(f"Training failed: {e}")
113
+ raise
114
+
115
+ def detect_language(self, text: str) -> str:
116
+ if re.search(r'[\u0900-\u097F]', text):
117
+ return 'hi'
118
+ elif re.search(r'[\u0980-\u09FF]', text):
119
+ return 'or'
120
+ else:
121
+ return 'en'
122
+
123
+ def get_language_confidence(self, text: str) -> float:
124
+ word_count = len(text.split())
125
+ if word_count < 5:
126
+ return 0.6
127
+ elif word_count < 15:
128
+ return 0.8
129
+ return 0.9
130
+
131
+ def _check_social_engineering(self, text: str):
132
+ """
133
+ Check for social engineering patterns the SMS dataset misses.
134
+ Returns (max_risk_score, list of matched reasons).
135
+ """
136
+ text_lower = text.lower()
137
+ matched = []
138
+ max_score = 0.0
139
+
140
+ for pattern, score, reason in self.social_engineering_patterns:
141
+ if re.search(pattern, text_lower):
142
+ matched.append(reason)
143
+ max_score = max(max_score, score)
144
+
145
+ return max_score, matched
146
+
147
+ def analyze_text_scam(self, text: str, language: str = None) -> Dict:
148
+ if not text or not text.strip():
149
+ return {
150
+ "risk_level": "Safe",
151
+ "confidence": 0.0,
152
+ "reasoning": "Empty text provided",
153
+ "detected_language": "unknown"
154
+ }
155
+
156
+ detected_language = language or self.detect_language(text)
157
+
158
+ try:
159
+ proba = self.model.predict_proba([text])[0]
160
+ spam_prob = proba[1]
161
+
162
+ # Social engineering check (catches vishing, OTP, KYC scams)
163
+ se_score, se_reasons = self._check_social_engineering(text)
164
+
165
+ # Blend ML + rule scores. Take the max, not average.
166
+ # This is intentionally conservative — we'd rather flag a safe message
167
+ # as suspicious than let a scam through.
168
+ effective_score = max(spam_prob, se_score)
169
+
170
+ # Conservative thresholds:
171
+ # Safe = below 20% combined risk. Anything above 20% gets at least Suspicious.
172
+ # Scam = above 55% (was 65% before — that was too lenient).
173
+ if effective_score >= 0.55:
174
+ risk_level = "Scam"
175
+ confidence = effective_score
176
+ elif effective_score >= 0.20:
177
+ risk_level = "Suspicious"
178
+ confidence = effective_score
179
+ else:
180
+ risk_level = "Safe"
181
+ confidence = 1 - effective_score
182
+
183
+ # Build reasoning
184
+ reasoning_parts = [f"Spam probability: {round(spam_prob * 100, 1)}%"]
185
+ if se_reasons:
186
+ reasoning_parts.append(f"Flags: {', '.join(se_reasons[:3])}")
187
+
188
+ return {
189
+ "risk_level": risk_level,
190
+ "confidence": round(confidence, 4),
191
+ "reasoning": " | ".join(reasoning_parts),
192
+ "detected_language": detected_language
193
+ }
194
+
195
+ except Exception as e:
196
+ logger.error(f"Classification failed: {e}")
197
+ return {
198
+ "risk_level": "Suspicious",
199
+ "confidence": 0.5,
200
+ "reasoning": f"Model error: {str(e)}",
201
+ "detected_language": detected_language
202
+ }
203
+
204
+ def analyze_url_scam(self, url: str, context: str = "") -> Dict:
205
+ if not url:
206
+ return {
207
+ "risk_level": "Safe",
208
+ "confidence": 0.0,
209
+ "reasoning": "No URL provided",
210
+ "domain": "",
211
+ "url_status": "invalid"
212
+ }
213
+
214
+ try:
215
+ if not url.startswith(('http://', 'https://')):
216
+ url = 'http://' + url
217
+
218
+ parsed_url = urlparse(url)
219
+ domain = parsed_url.netloc.lower()
220
+
221
+ rule_risk = 0.0
222
+ rule_reasons = []
223
+
224
+ scam_patterns = [
225
+ "faceb00k", "paypa1", "amaz0n", "micros0ft", "g00gle",
226
+ "appleid", "login-secure", "claim-your", "verify-account",
227
+ "lottery", "techsupport", "quickloan", "account-update"
228
+ ]
229
+
230
+ if any(pattern in domain for pattern in scam_patterns):
231
+ rule_risk += 0.8
232
+ rule_reasons.append("Domain mimics trusted brand")
233
+
234
+ if any(s in domain for s in self.suspicious_shorteners):
235
+ rule_risk += 0.6
236
+ rule_reasons.append("URL shortening service")
237
+
238
+ if re.search(r'\d+\.\d+\.\d+\.\d+', domain):
239
+ rule_risk += 0.9
240
+ rule_reasons.append("Uses raw IP address")
241
+
242
+ suspicious_tlds = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top', '.pw', '.click']
243
+ if any(domain.endswith(tld) for tld in suspicious_tlds):
244
+ rule_risk += 0.7
245
+ rule_reasons.append("Suspicious TLD")
246
+
247
+ if len(url) > 100:
248
+ rule_risk += 0.3
249
+ rule_reasons.append("Unusually long URL")
250
+
251
+ security_words = ['login', 'verify', 'update', 'confirm', 'secure', 'account', 'signin']
252
+ if any(word in url.lower() for word in security_words):
253
+ rule_risk += 0.4
254
+ rule_reasons.append("Security keywords in URL")
255
+
256
+ ml_risk = 0.0
257
+ context_risk_level = None
258
+ if context:
259
+ context_result = self.analyze_text_scam(context)
260
+ context_risk_level = context_result['risk_level']
261
+ if context_result['risk_level'] == 'Scam':
262
+ ml_risk = context_result['confidence']
263
+ elif context_result['risk_level'] == 'Suspicious':
264
+ ml_risk = context_result['confidence'] * 0.6
265
+
266
+ final_score = min((rule_risk * 0.65) + (ml_risk * 0.35), 1.0)
267
+
268
+ if final_score >= 0.55:
269
+ risk_level = "Scam"
270
+ elif final_score >= 0.25:
271
+ risk_level = "Suspicious"
272
+ else:
273
+ risk_level = "Safe"
274
+
275
+ rule_summary = "; ".join(rule_reasons) if rule_reasons else "No suspicious patterns"
276
+ reasoning = rule_summary
277
+ if context_risk_level:
278
+ reasoning += f" | Message context: {context_risk_level}"
279
+
280
+ return {
281
+ "risk_level": risk_level,
282
+ "confidence": round(final_score, 4),
283
+ "reasoning": reasoning,
284
+ "domain": domain,
285
+ "url_status": "analyzed"
286
+ }
287
+
288
+ except Exception as e:
289
+ return {
290
+ "risk_level": "Suspicious",
291
+ "confidence": 0.5,
292
+ "reasoning": f"URL analysis error: {str(e)}",
293
+ "domain": "unknown",
294
+ "url_status": "error"
295
+ }
296
+
297
+ def generate_user_response(self, risk_level: str) -> str:
298
+ responses = {
299
+ "Safe": "✅ This message appears safe.",
300
+ "Suspicious": "⚠️ Be cautious - this message has suspicious elements.",
301
+ "Scam": "🚨 WARNING: This appears to be a scam! Do not click links or share personal info."
302
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  return responses.get(risk_level, "Unable to analyze.")