Spaces:
Running on Zero
Running on Zero
Download app.py from ExistedYear/ScamShield: direct link, hf CLI and curl.
- Browser
- Download file 40.2 kB
-
https://huggingface.co/spaces/ExistedYear/ScamShield/resolve/main/app.py
- Command line
-
hf download hf://spaces/ExistedYear/ScamShield/app.py
-
curl -L -o app.py https://huggingface.co/spaces/ExistedYear/ScamShield/resolve/main/app.py
40.2 kB
| 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. | |
| 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 = ( | |
| '<div class="card"><div class="card-t">Why this verdict · SHAP word impact</div>' | |
| '<div class="row"><span class="sh-spin"></span> Computing word attribution…</div>' | |
| '<div class="rows" style="margin-top:.8rem">' | |
| '<div class="sh-row"><div class="sh-w" style="color:var(--faint)">…</div>' | |
| '<div class="sh-t"><div class="sh-b" style="left:50%;width:6%;background:var(--brand);opacity:.35"></div></div>' | |
| '<div class="sh-v"></div></div>' | |
| '<div class="sh-row"><div class="sh-w" style="color:var(--faint)">…</div>' | |
| '<div class="sh-t"><div class="sh-b" style="left:50%;width:4%;background:var(--brand);opacity:.25"></div></div>' | |
| '<div class="sh-v"></div></div>' | |
| '<div class="sh-row"><div class="sh-w" style="color:var(--faint)">…</div>' | |
| '<div class="sh-t"><div class="sh-b" style="left:50%;width:5%;background:var(--brand);opacity:.2"></div></div>' | |
| '<div class="sh-v"></div></div>' | |
| '</div></div>' | |
| ) | |
| 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'<div class="v v-{k}">', '<div class="v-top">'] | |
| out.append(f'<div class="v-ic ic-{k}">{icon}</div>') | |
| out.append(f'<div><div class="v-title">{title}</div><div class="v-sub">' | |
| f'{lvl} · detected as <b>{label}</b> · {esc(r.get("language","?"))}' | |
| "</div></div>") | |
| out.append(f'<div class="v-score"><b>{p}</b><i>%</i></div></div>') | |
| out.append(f'<div class="meter"><div class="meter-f" style="width:{p}%;background:{colour}"></div>' | |
| f'<div class="meter-t" style="left:55%"></div></div>') | |
| out.append('<div class="meter-s"><span>0% · ham</span><span>decision threshold 55%</span>' | |
| '<span>100% · scam</span></div>') | |
| # 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'<div class="meter-s" style="margin-top:.3rem">' | |
| f'<span>verdict {ms} ms on GPU</span>' | |
| f'<span>attribution computing…</span></div>') | |
| elif ms: | |
| out.append(f'<div class="meter-s" style="margin-top:.3rem">' | |
| f'<span>verdict {neural_ms} ms</span>' | |
| f'<span>total {ms} ms on GPU</span></div>') | |
| if r.get("green_channel"): | |
| out.append(f'<div class="alert alert-g" style="margin-top:1rem">' | |
| f'<b>🛡 Green Channel cleared</b> — {esc(r.get("green_reason",""))}</div>') | |
| out.append("</div>") | |
| 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'<span class="chip {cls}">{label}</span>' if on else f'<span class="chip ch-off">{label}</span>' | |
| 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'<div class="row">🔗 <b>{u.get("num_urls",0)} link(s)</b> detected in the message</div>') | |
| if t.get("num_chars"): | |
| rows.append(f'<div class="row">📏 {t["num_chars"]} characters · {t["num_words"]} tokens · ' | |
| f'{round(t.get("pct_digits",0)*100)}% digits</div>') | |
| rows.append(f'<div class="row mono" style="font-size:.74rem">neural <b>{r.get("_neural_score",0):.3f}</b>' | |
| f' · rule <b>{r.get("_rule_score",0):.3f}</b>' | |
| f' · ham-rule <b>{r.get("_ham_rule_score",0):.3f}</b></div>') | |
| toks = "".join(f'<span class="chip">{esc(w)}</span>' for w in r.get("_tokens", [])) | |
| if toks: | |
| rows.append(f'<div class="chips" style="margin-top:.55rem">{toks}</div>') | |
| return ('<div class="card"><div class="card-t">Signal breakdown</div>' | |
| f'<div class="chips">{"".join(chips)}</div>' | |
| f'<div class="rows" style="margin-top:1rem">{"".join(rows)}</div></div>') | |
| def h_shap(r, ex): | |
| words = list(ex.get("top_spam_words", [])) + list(ex.get("top_ham_words", [])) | |
| if not words: | |
| return ('<div class="card"><div class="card-t">Why this verdict</div>' | |
| '<div class="row">No strong word-level signals for this message.</div></div>') | |
| 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'<div class="sh-row"><div class="sh-w">{esc(w)}</div>' | |
| f'<div class="sh-t"><div class="sh-b" style="{st}"></div></div>' | |
| f'<div class="sh-v">{v:+.3f}</div></div>') | |
| return ('<div class="card"><div class="card-t">Why this verdict · SHAP word impact</div>' | |
| f'<div class="sh">{"".join(bars)}</div>' | |
| '<div class="sh-lg"><span><i style="background:var(--hi)"></i>pushes toward scam</span>' | |
| '<span><i style="background:var(--lo)"></i>pushes toward legitimate</span></div></div>') | |
| def h_warn(r): | |
| if r.get("label") != "spam": | |
| return "" | |
| return ('<div class="alert">⚠ <b>Do not act on this message.</b> Never click the link or share ' | |
| 'your OTP, Aadhaar or bank details.<br>▸ Block the sender · ▸ Report at ' | |
| '<code>cybercrime.gov.in</code> · ▸ National helpline <code>1930</code></div>') | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Handlers | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| 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 ('<div class="card"><div class="row">Type or paste a message to analyse it.</div></div>', | |
| "", "", "") | |
| 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)) | |
| 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'<div class="card"><div class="card-t">Analysis · {esc(SEED_SMS[i]["n"])}</div></div>' | |
| 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'<div class="in-r" onclick="void 0"><span class="in-d" style="background:{colour}"></span>' | |
| f'<div class="in-b"><div class="in-s">{esc(m["n"])}<em>{hit}</em></div>' | |
| f'<div class="in-x">{esc(m["b"])}</div></div>' | |
| f'<span class="in-l" style="background:{soft};color:{colour};border:1px solid {brd}">' | |
| f'{tag}</span></div>' | |
| ) | |
| progress(1, desc="done") | |
| stats = ('<div class="stats">' | |
| f'<div class="stat"><b>{len(SEED_SMS)}</b><span>Scanned</span></div>' | |
| f'<div class="stat"><b style="color:var(--hi)">{threats}</b><span>Flagged scam</span></div>' | |
| f'<div class="stat"><b style="color:var(--lo)">{len(SEED_SMS)-threats}</b><span>Safe</span></div>' | |
| f'<div class="stat"><b>{round(threats/len(SEED_SMS)*100)}%</b><span>Flag rate</span></div>' | |
| '</div>') | |
| return stats + f'<div class="in-list">{"".join(rows)}</div>' | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # JSON API — used by the Android app and any external client | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def predict_api(message: str) -> dict: | |
| return _run(message) | |
| 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 = ('<div class="nav"><div class="logo"><div class="logo-m">🛡</div>' | |
| 'Scam<span>Shield</span></div><div class="nav-r">' | |
| '<span class="pill pill-b"><span class="dotlive"></span> model live</span>' | |
| '<span class="pill">XLM-RoBERTa · GPU</span>' | |
| '<span class="pill">EN · HI · Hinglish</span></div></div>') | |
| HERO = ('<div class="hero"><div class="eyebrow">✦ AI for Cybersecurity</div>' | |
| '<h1>Catch the scam <em>before</em> it catches you</h1>' | |
| '<p>Paste any SMS — English, Hindi or Hinglish — and ScamShield returns a verdict, ' | |
| 'the exact signals behind it, and word-level SHAP attribution.</p></div>') | |
| FOOT = ('<div class="foot"><p><b>XLM-RoBERTa</b> · 9 URL + 8 text signals · Google Safe Browsing ' | |
| '· SHAP · threshold 0.55<br/>Spam F1 0.94 · multilingual · encrypted Android client ' | |
| 'ships as an APK on the same API</p></div>') | |
| def side_panel(): | |
| return ( | |
| '<div class="card"><div class="card-t">What you are looking at</div>' | |
| '<div class="rows">' | |
| '<div class="row">🧠 <b>XLM-RoBERTa</b> reads the message in 100 languages</div>' | |
| '<div class="row">🔗 <b>9 URL signals</b> — TLD, shorteners, raw IPs, whitelist</div>' | |
| '<div class="row">🛡 <b>Google Safe Browsing</b> escalates known-bad domains</div>' | |
| '<div class="row">📊 <b>SHAP</b> shows which words pushed the score</div>' | |
| '<div class="row">🎯 <b>Threshold 0.55</b> tuned to stop flagging Indian bank SMS</div>' | |
| '</div></div>' | |
| '<div class="card" style="margin-top:.9rem"><div class="card-t">Mobile app</div>' | |
| '<div class="row">The phone on the left is the real UI running this exact pipeline. ' | |
| 'The Android build reads your inbox (<code>READ_SMS</code>), encrypts every request ' | |
| 'with AES-256-CBC, and raises a notification when a message is flagged.</div></div>' | |
| ) | |
| with gr.Blocks(title="ScamShield — Smishing Detector") as demo: | |
| gr.HTML('<div class="glow"></div>') | |
| 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('<div class="card-t">Paste a message</div>') | |
| 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('<div class="notch"></div>') | |
| gr.HTML('<div class="ph-top"><div class="ph-brand">🛡 <span>ScamShield</span></div>' | |
| '<div class="ph-tabs"><button class="pt pt-a">Inbox</button>' | |
| '<button class="pt">Scan</button></div></div>') | |
| stats = gr.HTML('<div class="stats"><div class="stat"><b>—</b><span>Scanned</span></div>' | |
| '<div class="stat"><b>—</b><span>Flagged</span></div>' | |
| '<div class="stat"><b>—</b><span>Safe</span></div></div>') | |
| 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('<div class="ph-note">Browsers cannot read an SMS inbox, so the web ' | |
| 'demo ships a seeded set. On Android the app reads your real messages.</div>') | |
| 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) |