import sys, os, time sys.path.insert(0, os.path.join(os.path.dirname(__file__), "smishing_detector")) import gradio as gr import spaces from predictor import SmishingPredictor from explainability.shap_explainer import SmishingExplainer CHECKPOINT = os.path.join(os.path.dirname(__file__), "smishing_detector", "best_model.pt") MODEL_REPO = os.getenv("SCAMSHIELD_MODEL_REPO", "ExistedYear/ScamShield-model") # SCAMSHIELD_GPU=0 serves entirely on CPU. Every @spaces.GPU call has to move # 1.11 GB of weights onto the metered GPU, so on the free tier that burns the # daily quota after a handful of scans. CPU inference is slower per message but # unmetered, which is the better trade for a demo that must not die. USE_GPU = os.getenv("SCAMSHIELD_GPU", "0").strip().lower() in {"1", "true", "yes", "on"} def gpu(fn): """Apply @spaces.GPU only when GPU serving is enabled.""" return spaces.GPU(fn) if USE_GPU else fn # ZeroGPU hardware refuses to start unless it finds at least one @spaces.GPU # function during startup ("No @spaces.GPU function detected"). This one is never # wired to an endpoint — it exists only so the runtime check passes while every # request below stays on unmetered CPU. @spaces.GPU def _zerogpu_startup_probe(): return "ok" print(f"Serving mode: {'GPU (ZeroGPU quota applies)' if USE_GPU else 'CPU (unmetered)'}") if not os.path.exists(CHECKPOINT): print(f"Checkpoint not found locally, downloading from {MODEL_REPO}...") from huggingface_hub import hf_hub_download import shutil os.makedirs(os.path.dirname(CHECKPOINT), exist_ok=True) shutil.copyfile(hf_hub_download(repo_id=MODEL_REPO, filename="best_model.pt"), CHECKPOINT) print(f"Weights downloaded to {CHECKPOINT}") print("Loading ScamShield model...") predictor = SmishingPredictor(CHECKPOINT) print("Loading SHAP explainer...") explainer = SmishingExplainer(predictor) print("Model loaded. Ready.") # Presence check only — never prints the key itself. from utils.safe_browsing import get_checker as _gsb print(f"GSB key configured: {bool(os.getenv('GOOGLE_SAFE_BROWSING_API_KEY'))} " f"| fallback domains: {len(_gsb().fallback_legit_domains)}") # ───────────────────────────────────────────────────────────────────────────── # Design system # ───────────────────────────────────────────────────────────────────────────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap'); .gradio-container{ --bg:#08080c; --bg2:#0e0e15; --panel:rgba(255,255,255,.032); --panel2:rgba(255,255,255,.055); --line:rgba(255,255,255,.085); --line2:rgba(255,255,255,.14); --ink:#fafafa; --muted:#a1a1aa; --faint:#6b6b76; --brand:#818cf8; --brand2:#c084fc; --hi:#fb7185; --hi-s:rgba(251,113,133,.13); --hi-b:rgba(251,113,133,.34); --md:#fbbf24; --md-s:rgba(251,191,36,.13); --md-b:rgba(251,191,36,.34); --lo:#34d399; --lo-s:rgba(52,211,153,.13); --lo-b:rgba(52,211,153,.34); background:var(--bg)!important; color:var(--ink)!important; font-family:'Inter',system-ui,sans-serif!important; max-width:1080px!important; margin:0 auto!important; padding:0!important; } .gradio-container *{font-family:'Inter',system-ui,sans-serif!important} .gradio-container .mono, .mono{font-family:'JetBrains Mono',monospace!important} body{background:var(--bg)!important} .gradio-container > footer, footer{display:none!important} .dark{--bg:#08080c} /* ── ambient glow ── */ .glow{position:fixed;inset:0;pointer-events:none;z-index:0; background: radial-gradient(620px 340px at 18% -8%, rgba(129,140,248,.16), transparent 62%), radial-gradient(520px 300px at 88% 4%, rgba(192,132,252,.12), transparent 60%); } .gradio-container{position:relative;z-index:1} /* ── nav ── */ .nav{display:flex;align-items:center;justify-content:space-between; padding:1.15rem 0 1.35rem;border-bottom:1px solid var(--line)} .logo{display:flex;align-items:center;gap:.6rem;font-weight:800;font-size:1.02rem;letter-spacing:-.02em} .logo-m{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;font-size:.95rem; background:linear-gradient(140deg,var(--brand),var(--brand2));color:#0b0b12; box-shadow:0 6px 20px rgba(129,140,248,.42)} .logo span{color:var(--brand)} .nav-r{display:flex;gap:.4rem;align-items:center;flex-wrap:wrap} .pill{display:inline-flex;align-items:center;gap:.35rem;font-size:.68rem;font-weight:600; padding:.3rem .68rem;border-radius:99px;border:1px solid var(--line2);color:var(--muted); background:var(--panel)} .pill-b{border-color:rgba(129,140,248,.4);color:var(--brand);background:rgba(129,140,248,.1)} .dotlive{width:6px;height:6px;border-radius:50%;background:var(--lo); box-shadow:0 0 0 3px rgba(52,211,153,.16)} /* ── hero ── */ .hero{padding:2.6rem 0 2.1rem;text-align:center} .eyebrow{display:inline-flex;align-items:center;gap:.45rem;font-size:.7rem;font-weight:700; letter-spacing:.06em;text-transform:uppercase;color:var(--brand); background:rgba(129,140,248,.1);border:1px solid rgba(129,140,248,.3); padding:.34rem .8rem;border-radius:99px;margin-bottom:1.15rem} .hero h1{font-size:clamp(2rem,5.2vw,3.35rem);font-weight:900;line-height:1.06; letter-spacing:-.045em;margin:0} .hero h1 em{font-style:normal;background:linear-gradient(100deg,var(--brand),var(--brand2)); -webkit-background-clip:text;background-clip:text;color:transparent} .hero p{color:var(--muted);font-size:1rem;line-height:1.6;margin:.85rem auto 0;max-width:640px} /* ── surfaces ── */ .card{background:var(--panel);border:1px solid var(--line);border-radius:18px; padding:1.35rem 1.45rem;backdrop-filter:blur(8px)} .card-t{font-size:.7rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase; color:var(--faint);margin-bottom:.9rem;display:flex;align-items:center;gap:.45rem} .card-t::before{content:'';width:3px;height:12px;border-radius:2px; background:linear-gradient(var(--brand),var(--brand2))} /* ── input ── */ .ta textarea{background:var(--bg2)!important;border:1.5px solid var(--line2)!important; border-radius:14px!important;color:var(--ink)!important;font-size:.92rem!important; line-height:1.6!important;padding:.9rem 1rem!important;resize:vertical} .ta textarea:focus{border-color:var(--brand)!important;box-shadow:0 0 0 4px rgba(129,140,248,.14)!important} .ta textarea::placeholder{color:var(--faint)!important} .scan-btn button{background:linear-gradient(135deg,var(--brand),var(--brand2))!important; border:none!important;color:#0b0b12!important;font-weight:800!important;font-size:.92rem!important; border-radius:13px!important;height:44px;box-shadow:0 8px 26px rgba(129,140,248,.34)!important; transition:transform .15s, box-shadow .15s} .scan-btn button:hover{transform:translateY(-1px);box-shadow:0 12px 32px rgba(129,140,248,.44)!important} /* examples strip */ .examples{border:0!important;background:transparent!important;padding:0!important;gap:.35rem!important} .examples table{display:none!important} .examples .label,.examples span,.examples>div>span{display:none!important} .examples button{background:var(--panel)!important;border:1px solid var(--line)!important; color:var(--muted)!important;font-size:.74rem!important;font-weight:500!important; border-radius:99px!important;padding:.4rem .85rem!important;text-align:left!important; max-width:100%;line-height:1.4;white-space:normal!important;height:auto!important} .examples button:hover{border-color:var(--brand)!important;color:var(--ink)!important; background:rgba(129,140,248,.1)!important} /* ── verdict ── */ .v{border-radius:18px;padding:1.3rem 1.4rem;border:1px solid var(--line2); background:var(--panel);position:relative;overflow:hidden} .v::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px} .v-hi{border-color:var(--hi-b);background:linear-gradient(180deg,var(--hi-s),transparent 60%)} .v-hi::before{background:var(--hi)} .v-md{border-color:var(--md-b);background:linear-gradient(180deg,var(--md-s),transparent 60%)} .v-md::before{background:var(--md)} .v-lo{border-color:var(--lo-b);background:linear-gradient(180deg,var(--lo-s),transparent 60%)} .v-lo::before{background:var(--lo)} .v-top{display:flex;align-items:flex-start;gap:.9rem} .v-ic{width:46px;height:46px;border-radius:13px;display:grid;place-items:center;font-size:1.3rem; flex-shrink:0;border:1px solid} .ic-hi{background:var(--hi-s);border-color:var(--hi-b)} .ic-md{background:var(--md-s);border-color:var(--md-b)} .ic-lo{background:var(--lo-s);border-color:var(--lo-b)} .v-title{font-size:1.28rem;font-weight:800;letter-spacing:-.025em;line-height:1.25} .v-sub{font-size:.8rem;color:var(--muted);margin-top:.22rem;display:flex;gap:.5rem; align-items:center;flex-wrap:wrap} .v-score{margin-left:auto;text-align:right;line-height:1;flex-shrink:0} .v-score b{font-size:2.3rem;font-weight:900;letter-spacing:-.05em} .v-score i{font-style:normal;font-size:.9rem;font-weight:700;color:var(--faint);margin-left:1px} .hi .v-score b{color:var(--hi)} .md .v-score b{color:var(--md)} .lo .v-score b{color:var(--lo)} .meter{position:relative;height:8px;border-radius:99px;background:rgba(255,255,255,.07); margin-top:1.15rem;overflow:visible} .meter-f{height:100%;border-radius:99px;transition:width 1s cubic-bezier(.22,1,.36,1)} .meter-t{position:absolute;top:-4px;bottom:-4px;width:2px;background:var(--ink);opacity:.55; border-radius:2px} .meter-t::after{content:'55';position:absolute;top:-15px;left:50%;transform:translateX(-50%); font-size:.6rem;font-weight:700;color:var(--faint)} .meter-s{display:flex;justify-content:space-between;margin-top:.5rem;font-size:.66rem;color:var(--faint)} /* ── signal chips ── */ .chips{display:flex;flex-wrap:wrap;gap:.35rem} .chip{font-size:.71rem;font-weight:600;padding:.32rem .7rem;border-radius:9px; border:1px solid var(--line2);background:var(--panel2);color:var(--muted)} .ch-hi{border-color:var(--hi-b);background:var(--hi-s);color:var(--hi)} .ch-md{border-color:var(--md-b);background:var(--md-s);color:var(--md)} .ch-lo{border-color:var(--lo-b);background:var(--lo-s);color:var(--lo)} .ch-off{opacity:.42} .rows{display:flex;flex-direction:column;gap:.42rem} .row{display:flex;gap:.6rem;align-items:flex-start;font-size:.8rem;color:var(--muted);line-height:1.6} .row b{color:var(--ink);font-weight:600} /* ── SHAP ── */ .sh{display:flex;flex-direction:column;gap:.42rem} .sh-spin{width:13px;height:13px;border:2px solid var(--line2);border-top-color:var(--brand); border-radius:50%;animation:shspin .7s linear infinite;display:inline-block;margin-right:.5rem; vertical-align:-2px} @keyframes shspin{to{transform:rotate(360deg)}} .sh-row{display:flex;align-items:center;gap:.7rem} .sh-w{width:120px;text-align:right;font-size:.74rem;font-weight:600;color:var(--muted); white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .sh-t{flex:1;position:relative;height:20px} .sh-t::before{content:'';position:absolute;left:50%;top:-3px;bottom:-3px;width:1px; background:var(--line2)} .sh-b{position:absolute;height:11px;border-radius:4px;top:4px} .sh-v{width:52px;font-size:.7rem;color:var(--faint);font-variant-numeric:tabular-nums} .sh-lg{display:flex;gap:1rem;font-size:.66rem;color:var(--faint);margin-top:.7rem; padding-top:.6rem;border-top:1px solid var(--line)} .sh-lg i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:.3rem} /* ── alert ── */ .alert{border-radius:14px;padding:.95rem 1.1rem;font-size:.81rem;line-height:1.75; color:var(--muted);border:1px solid var(--hi-b);background:var(--hi-s)} .alert b{color:var(--ink)} .alert code{background:rgba(0,0,0,.32);border:1px solid var(--line2);border-radius:5px; padding:.05rem .35rem;color:var(--ink);font-size:.78rem} .alert-g{border-color:var(--lo-b);background:var(--lo-s)} /* ── stats strip ── */ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:.6rem} .stat{background:var(--panel2);border:1px solid var(--line);border-radius:13px;padding:.8rem .9rem} .stat b{display:block;font-size:1.5rem;font-weight:800;letter-spacing:-.03em;line-height:1.1} .stat span{font-size:.65rem;color:var(--faint);text-transform:uppercase;letter-spacing:.07em; font-weight:600} /* ── phone ── */ .mob{display:grid;grid-template-columns:396px 1fr;gap:1.6rem;align-items:start;margin-top:.4rem} @media(max-width:900px){.mob{grid-template-columns:1fr}} .phone{background:#0b0b12;border:9px solid #05050a;border-radius:38px;padding:1rem .85rem 1.2rem; box-shadow:0 26px 70px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.06)} .notch{width:112px;height:17px;background:#05050a;border-radius:0 0 11px 11px;margin:0 auto .8rem} .ph-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem} .ph-brand{font-size:.86rem;font-weight:800;display:flex;align-items:center;gap:.4rem} .ph-brand span{color:var(--brand)} .ph-tabs{display:flex;gap:.2rem;background:rgba(255,255,255,.05);border-radius:9px;padding:.2rem; border:1px solid var(--line)} .pt{font-size:.68rem;font-weight:700;color:var(--faint);background:transparent;border:none; border-radius:7px;padding:.26rem .58rem} .pt-a{background:rgba(255,255,255,.09);color:var(--ink)} .in-list{border:1px solid var(--line);border-radius:13px;overflow:hidden;max-height:330px; overflow-y:auto} .in-r{display:flex;gap:.6rem;align-items:flex-start;padding:.65rem .7rem; border-bottom:1px solid var(--line);cursor:pointer;transition:background .15s} .in-r:last-child{border-bottom:none} .in-r:hover{background:rgba(255,255,255,.04)} .in-d{width:7px;height:7px;border-radius:50%;margin-top:.42rem;flex-shrink:0;background:var(--faint)} .in-b{flex:1;min-width:0} .in-s{font-size:.68rem;font-weight:700;color:var(--ink);display:flex;gap:.4rem;align-items:center} .in-s em{font-style:normal;font-size:.6rem;font-weight:700;color:var(--faint); text-transform:uppercase;letter-spacing:.05em} .in-x{font-size:.73rem;color:var(--muted);line-height:1.45;margin-top:.15rem; display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} .in-l{font-size:.57rem;font-weight:800;letter-spacing:.05em;padding:.14rem .42rem;border-radius:99px; flex-shrink:0;margin-top:.15rem} .ph-note{font-size:.72rem;color:var(--faint);line-height:1.65;margin:.7rem 0 0;text-align:center} /* ── footer ── */ .foot{text-align:center;padding:2.4rem 0 1.6rem;border-top:1px solid var(--line);margin-top:2.2rem} .foot p{font-size:.73rem;color:var(--faint);line-height:1.9} .foot b{color:var(--muted);font-weight:600} /* gradio tab styling */ .tabs > .tab-nav > button{border-radius:10px!important;font-weight:700!important;font-size:.85rem!important; color:var(--faint)!important;padding:.55rem 1rem!important} .tabs > .tab-nav > button.selected{color:var(--ink)!important;background:var(--panel2)!important} .block-label,.label-wrap span{font-size:.7rem!important;font-weight:700!important; letter-spacing:.05em;text-transform:uppercase;color:var(--faint)!important} /* hidden plumbing for the JSON API */ .api-hidden{display:none!important} """ # ───────────────────────────────────────────────────────────────────────────── # Seeded inbox (used by the Mobile tab; browsers/iOS cannot read a real inbox) # ───────────────────────────────────────────────────────────────────────────── SEED_SMS = [ {"s": "SBI-ALERT", "n": "+91-SBI", "b": "URGENT: Your SBI account has been suspended. Verify KYC now at http://sbi-kyc-verify.xyz or funds will be frozen.", "e": "spam"}, {"s": "Lottery", "n": "+91-VD", "b": "CONGRATULATIONS! You have been selected as the lucky winner of Rs.5,00,000. Claim before it expires: http://prize-winner.xyz/claim", "e": "spam"}, {"s": "DHL", "n": "+91-DHL", "b": "Your package could not be delivered. Pay Rs.2.99 customs fee at http://dhl-fee.net/track or it will be returned.", "e": "spam"}, {"s": "CBI", "n": "+91-ED", "b": "CBI notice: Aapke khilaf money laundering case darj. Digital arrest warrant. Call 9911000000.", "e": "spam"}, {"s": "Bijli", "n": "+91-MSEB", "b": "Aapka bijli connection aaj raat 9:30 baje band ho jayega. Turant call karein: 9876543210", "e": "spam"}, {"s": "UPI", "n": "+91-PSB", "b": "Dear user, your UPI ID expires in 24h. Renew at http://upi-renew.club/verify", "e": "spam"}, {"s": "Secure-login", "n": "+91-WEB", "b": "Your account is compromised. Verify immediately at http://192.168.44.12/secure-login", "e": "spam"}, {"s": "लोटरी", "n": "+91-LTR", "b": "बधाई हो! आपको KBC लॉटरी में ₹25,00,000 का इनाम मिला। http://kbc-lottery.ml/win", "e": "spam"}, {"s": "बिजली", "n": "+91-UP", "b": "प्रिय ग्राहक, आपका बिजली कनेक्शन आज रात 9:30 बजे काट दिया जाएगा। अभी कॉल करें: 9876543210", "e": "spam"}, {"s": "HDFC Bank", "n": "HDFC-BK", "b": "HDFC Bank: Rs.25,000 credited to a/c XX4521 on 02-May. Avl bal: Rs.1,42,356. -HDFC Bank", "e": "ham"}, {"s": "OTP", "n": "SMS-SBI", "b": "Your OTP for SBI Net Banking login is 483921. Valid for 10 minutes. Do not share. -SBI", "e": "ham"}, {"s": "Airtel", "n": "AIRTEL", "b": "Airtel Thanks! Your recharge of Rs.239 is successful. Validity: 28 days. Data: 1.5GB/day. -Airtel", "e": "ham"}, {"s": "PhonePe", "n": "PHONEPE", "b": "PhonePe: Rs.500 received from Rahul Kumar. UPI Ref: 4893721019.", "e": "ham"}, {"s": "Amazon", "n": "AMAZON", "b": "Your Amazon order #402-9876543 is out for delivery today. Track: amzn.in/track -Amazon", "e": "ham"}, {"s": "IRCTC", "n": "IRCTC", "b": "Your IRCTC ticket PNR 4567891230 is confirmed. Train 12345 on 05-May. Seat: S4/32. -IRCTC", "e": "ham"}, {"s": "Mum", "n": "+91-FAM", "b": "Mom, I landed safely. Will call from the hotel in an hour.", "e": "ham"}, ] SEED_LABELS = [m["n"] for m in SEED_SMS] EXAMPLES = [ "URGENT: Your SBI account has been suspended. Verify KYC at http://sbi-kyc-verify.xyz", "HDFC Bank: Rs.25,000 credited to a/c XX4521. Avl bal: Rs.1,42,356. -HDFC Bank", "Aapka bijli connection aaj raat 9:30 baje band ho jayega. Turant call karein: 9876543210", "CBI notice: Aapke khilaf money laundering case darj. Digital arrest warrant. Call 9911000000.", "PhonePe: Rs.500 received from Rahul Kumar. UPI Ref: 4893721019.", "बधाई हो! आपको KBC लॉटरी में ₹25,00,000 का इनाम मिला। http://kbc-lottery.ml/win", "Your OTP for SBI login is 483921. Valid 10 mins. Do NOT share. -SBI", "Mom, I landed safely. Will call from the hotel in an hour.", ] def esc(x): return str(x).replace("&", "&").replace("<", "<").replace(">", ">") def risk(conf, level=""): if conf >= 0.75 or level == "high": return "hi", "var(--hi)", "High risk", "🔴" if conf >= 0.55 or level == "medium": return "md", "var(--md)", "Medium risk", "🟠" return "lo", "var(--lo)", "Low risk", "🟢" _CACHE, _CACHE_MAX = {}, 256 def _cache_get(key): return _CACHE.get(key) def _cache_put(key, value): if len(_CACHE) >= _CACHE_MAX: _CACHE.clear() _CACHE[key] = value return value def _run(message): key = ("run", message) hit = _cache_get(key) if hit is not None: return hit r = predictor.predict(message) r["_tokens"] = [w for w in str(message).lower().split() if w.isalpha() and len(w) > 3][:14] return _cache_put(key, r) def _explain(message): key = ("ex", message) hit = _cache_get(key) if hit is not None: return hit try: # 8 features keeps the SHAP kernel small — this is the expensive part. out = explainer.explain_text(message, num_features=8) except Exception: out = {"top_spam_words": [], "top_ham_words": []} return _cache_put(key, out) # ── renderers ──────────────────────────────────────────────────────────────── SHAP_PENDING = ( '
Why this verdict · SHAP word impact
' '
Computing word attribution…
' '
' '
…
' '
' '
' '
…
' '
' '
' '
…
' '
' '
' '
' ) def h_verdict(r, msg="", ms=None, neural_ms=None): p = round(max(0.0, min(1.0, r.get("confidence", 0))) * 100) k, colour, lvl, icon = risk(r.get("confidence", 0), r.get("risk_level", "")) label = r.get("label", "ham") title = {"spam": "Scam detected", "safe": "Safe", "ham": "Looks legitimate"}.get(label, "Scam detected") out = [f'
', '
'] out.append(f'
{icon}
') out.append(f'
{title}
' f'{lvl} · detected as {label} · {esc(r.get("language","?"))}' "
") out.append(f'
{p}%
') out.append(f'
' f'
') out.append('
0% · hamdecision threshold 55%' '100% · scam
') # Timing is honest about which stage it reflects: while streaming, `neural_ms` # equals `ms`, so we only claim a verdict timing — never a "total". if ms and neural_ms and neural_ms == ms: out.append(f'
' f'verdict {ms} ms on GPU' f'attribution computing…
') elif ms: out.append(f'
' f'verdict {neural_ms} ms' f'total {ms} ms on GPU
') if r.get("green_channel"): out.append(f'
' f'🛡 Green Channel cleared — {esc(r.get("green_reason",""))}
') out.append("
") return "".join(out) def h_signals(r): u, t = r.get("url_signals", {}), r.get("text_signals", {}) chips = [] def chip(on, label, state): cls = {"hi": "ch-hi", "md": "ch-md", "lo": "ch-lo", "off": "ch-off"}[state] return f'{label}' if on else f'{label}' chips.append(chip(u.get("has_url"), "🔗 URL present", "md" if u.get("has_url") else "off")) chips.append(chip(u.get("suspicious_tld"), "⚠ suspicious TLD", "hi" if u.get("suspicious_tld") else "off")) chips.append(chip(u.get("has_ip_url"), "🖥 raw IP host", "hi" if u.get("has_ip_url") else "off")) chips.append(chip(u.get("has_shortened_url"), "🔽 shortener", "hi" if u.get("has_shortened_url") else "off")) chips.append(chip(u.get("has_legit_domain"), "✓ known domain", "lo" if u.get("has_legit_domain") else "off")) chips.append(chip(u.get("has_http") and not u.get("has_https"), "⚠ plain HTTP", "md" if (u.get("has_http") and not u.get("has_https")) else "off")) chips.append(chip(t.get("has_phone"), "☎ phone number", "md" if t.get("has_phone") else "off")) chips.append(chip(t.get("urgency_count"), "⚡ urgency ×{0}".format(t.get("urgency_count", 0)), "hi" if (t.get("urgency_count") or 0) >= 3 else "md" if t.get("urgency_count") else "off")) chips.append(chip(t.get("pct_upper", 0) > 0.2, "⬆ shouty caps", "md" if t.get("pct_upper", 0) > 0.2 else "off")) chips.append(chip(t.get("has_currency"), "₹ currency", "off")) rows = [] if u.get("has_url"): rows.append(f'
🔗 {u.get("num_urls",0)} link(s) detected in the message
') if t.get("num_chars"): rows.append(f'
📏 {t["num_chars"]} characters · {t["num_words"]} tokens · ' f'{round(t.get("pct_digits",0)*100)}% digits
') rows.append(f'
neural {r.get("_neural_score",0):.3f}' f' · rule {r.get("_rule_score",0):.3f}' f' · ham-rule {r.get("_ham_rule_score",0):.3f}
') toks = "".join(f'{esc(w)}' for w in r.get("_tokens", [])) if toks: rows.append(f'
{toks}
') return ('
Signal breakdown
' f'
{"".join(chips)}
' f'
{"".join(rows)}
') def h_shap(r, ex): words = list(ex.get("top_spam_words", [])) + list(ex.get("top_ham_words", [])) if not words: return ('
Why this verdict
' '
No strong word-level signals for this message.
') rows = sorted(words, key=lambda w: -abs(w[1]))[:12] mx = max(abs(w[1]) for w in rows) or 1 bars = [] for w, v in rows: pct = (abs(v) / mx) * 47 st = (f"left:50%;width:{pct}%;background:var(--hi)" if v > 0 else f"right:50%;width:{pct}%;background:var(--lo)") bars.append(f'
{esc(w)}
' f'
' f'
{v:+.3f}
') return ('
Why this verdict · SHAP word impact
' f'
{"".join(bars)}
' '
pushes toward scam' 'pushes toward legitimate
') def h_warn(r): if r.get("label") != "spam": return "" return ('
⚠ Do not act on this message. Never click the link or share ' 'your OTP, Aadhaar or bank details.
▸ Block the sender  ·  ▸ Report at ' 'cybercrime.gov.in  ·  ▸ National helpline 1930
') # ───────────────────────────────────────────────────────────────────────────── # Handlers # ───────────────────────────────────────────────────────────────────────────── @gpu def scan(message, progress=gr.Progress()): """ Streaming scan: yields the verdict as soon as the neural pass finishes, then yields again once SHAP attribution lands. Judging flow wants the verdict fast (~1-2s) but also wants the word-level attribution on screen. Blocking on SHAP hides the verdict behind the slowest step, so this is a generator rather than a single return. """ if not message or not message.strip(): yield ('
Type or paste a message to analyse it.
', "", "", "") return t0 = time.perf_counter() r = _run(message) neural_ms = int((time.perf_counter() - t0) * 1000) # First yield: verdict + signals, SHAP panel still loading. # Pass neural_ms as BOTH args so h_verdict knows SHAP has not run yet. yield (h_verdict(r, message, neural_ms, neural_ms), h_signals(r), SHAP_PENDING, h_warn(r)) progress(0.5, desc="Generating word-level attribution…") ex = _explain(message) total_ms = int((time.perf_counter() - t0) * 1000) # Second yield: SHAP resolved, timing folded into the verdict card. yield (h_verdict(r, message, total_ms, neural_ms), h_signals(r), h_shap(r, ex), h_warn(r)) @gpu def mob_detail(which, custom="", progress=gr.Progress()): """Single seeded-message analysis — same streaming treatment as the scanner.""" i = SEED_LABELS.index(which) if which in SEED_LABELS else 0 msg = custom.strip() or SEED_SMS[i]["b"] head = f'
Analysis · {esc(SEED_SMS[i]["n"])}
' r = _run(msg) yield head + h_verdict(r, msg) + h_signals(r) + SHAP_PENDING + h_warn(r) progress(0.5, desc="Generating word-level attribution…") ex = _explain(msg) yield head + h_verdict(r, msg) + h_signals(r) + h_shap(r, ex) + h_warn(r) def mob_scan_all(progress=gr.Progress()): """Bulk scan of the seeded inbox. Deliberately NOT decorated with @gpu — it always runs on CPU so that walking 16 messages never touches the metered GPU. """ rows, threats = [], 0 for i, m in enumerate(SEED_SMS): progress(i / len(SEED_SMS), desc=f"Scanning {i+1}/{len(SEED_SMS)} · {m['n']}") try: res = _run(m["b"]) except Exception: res = None spam = bool(res) and res.get("label") == "spam" threats += spam if res is None: tier, colour, soft, brd, tag = "lo", "var(--faint)", "var(--panel2)", "var(--line2)", "—" else: # Same three tiers as the detail card: high → SCAM, medium → SUSPICIOUS. tier, colour, _, _ = risk(res.get("confidence", 0), res.get("risk_level", "")) if tier == "hi": soft, brd, tag = "var(--hi-s)", "var(--hi-b)", "SCAM" elif tier == "md": soft, brd, tag = "var(--md-s)", "var(--md-b)", "SUSPICIOUS" else: soft, brd, tag = "var(--lo-s)", "var(--lo-b)", "SAFE" hit = "expected scam" if (spam == (m["e"] == "spam")) else "expected safe" rows.append( f'
' f'
{esc(m["n"])}{hit}
' f'
{esc(m["b"])}
' f'' f'{tag}
' ) progress(1, desc="done") stats = ('
' f'
{len(SEED_SMS)}Scanned
' f'
{threats}Flagged scam
' f'
{len(SEED_SMS)-threats}Safe
' f'
{round(threats/len(SEED_SMS)*100)}%Flag rate
' '
') return stats + f'
{"".join(rows)}
' # ───────────────────────────────────────────────────────────────────────────── # JSON API — used by the Android app and any external client # ───────────────────────────────────────────────────────────────────────────── @gpu def predict_api(message: str) -> dict: return _run(message) @gpu def explain_api(message: str) -> dict: r = _run(message) ex = _explain(message) return {"label": r["label"], "confidence": r["confidence"], "top_spam_words": ex.get("top_spam_words", []), "top_ham_words": ex.get("top_ham_words", [])} def check_domain_api(domain: str) -> dict: from utils.safe_browsing import check_domain_status st = check_domain_status(domain) return {"domain": domain, "status": st, "is_legitimate": st == "known_safe", "is_malicious": st == "known_malicious"} # ───────────────────────────────────────────────────────────────────────────── # UI # ───────────────────────────────────────────────────────────────────────────── NAV = ('') HERO = ('
✦ AI for Cybersecurity
' '

Catch the scam before it catches you

' '

Paste any SMS — English, Hindi or Hinglish — and ScamShield returns a verdict, ' 'the exact signals behind it, and word-level SHAP attribution.

') FOOT = ('

XLM-RoBERTa · 9 URL + 8 text signals · Google Safe Browsing ' '· SHAP · threshold 0.55
Spam F1 0.94 · multilingual · encrypted Android client ' 'ships as an APK on the same API

') def side_panel(): return ( '
What you are looking at
' '
' '
🧠 XLM-RoBERTa reads the message in 100 languages
' '
🔗 9 URL signals — TLD, shorteners, raw IPs, whitelist
' '
🛡 Google Safe Browsing escalates known-bad domains
' '
📊 SHAP shows which words pushed the score
' '
🎯 Threshold 0.55 tuned to stop flagging Indian bank SMS
' '
' '
Mobile app
' '
The phone on the left is the real UI running this exact pipeline. ' 'The Android build reads your inbox (READ_SMS), encrypts every request ' 'with AES-256-CBC, and raises a notification when a message is flagged.
' ) with gr.Blocks(title="ScamShield — Smishing Detector") as demo: gr.HTML('
') gr.HTML(NAV) gr.HTML(HERO) with gr.Tabs(): with gr.TabItem("Scanner"): with gr.Row(): with gr.Column(scale=7, elem_classes="ta"): gr.HTML('
Paste a message
') inp = gr.Textbox(show_label=False, lines=6, max_lines=12, placeholder="Paste the SMS here…\n\nCtrl + Enter to scan", elem_classes="ta") btn = gr.Button("Scan message", variant="primary", elem_classes="scan-btn") gr.Examples(examples=EXAMPLES, inputs=inp, label="Try an example") with gr.Column(scale=5): verdict = gr.HTML() warn = gr.HTML() with gr.Row(): with gr.Column(): signals = gr.HTML() with gr.Column(): shap = gr.HTML() for e in (btn.click, inp.submit): e(fn=scan, inputs=inp, outputs=[verdict, signals, shap, warn], api_name="scan", show_progress="minimal") with gr.TabItem("Mobile app"): with gr.Row(): with gr.Column(scale=5, elem_classes="phone"): gr.HTML('
') gr.HTML('
🛡 ScamShield
' '
' '
') stats = gr.HTML('
—Scanned
' '
—Flagged
' '
—Safe
') pick = gr.Dropdown(choices=SEED_LABELS, value=SEED_LABELS[0], show_label=False) custom = gr.Textbox(show_label=False, lines=2, max_lines=5, placeholder="…or paste any SMS", elem_classes="ta") with gr.Row(): go_one = gr.Button("Scan one", variant="primary", elem_classes="scan-btn") go_all = gr.Button("Scan all 16", variant="secondary") gr.HTML('
Browsers cannot read an SMS inbox, so the web ' 'demo ships a seeded set. On Android the app reads your real messages.
') detail = gr.HTML() with gr.Column(scale=4): gr.HTML(side_panel()) go_one.click(fn=mob_detail, inputs=[pick, custom], outputs=detail, show_progress="hidden") go_all.click(fn=mob_scan_all, outputs=stats, show_progress="minimal") gr.HTML(FOOT) # ── Hidden JSON endpoints ──────────────────────────────────────────────── # A function only becomes an API route once it is wired to an event, so the # JSON API is exposed through zero-height components rather than by being # called directly. _api_msg = gr.Textbox(visible=False, elem_classes="api-hidden") _api_json = gr.JSON(visible=False) _api_dom = gr.Textbox(visible=False) _api_dom_json = gr.JSON(visible=False) _api_msg.change(fn=predict_api, inputs=_api_msg, outputs=_api_json, api_name="predict", show_progress="hidden") _api_msg.change(fn=explain_api, inputs=_api_msg, outputs=_api_json, api_name="explain", show_progress="hidden") _api_dom.change(fn=check_domain_api, inputs=_api_dom, outputs=_api_dom_json, api_name="check_domain", show_progress="hidden") THEME = gr.themes.Soft(primary_hue="indigo", neutral_hue="slate", font=["Inter", "system-ui", "sans-serif"]) if __name__ == "__main__": demo.queue(default_concurrency_limit=2).launch(css=CSS, theme=THEME)