#!/usr/bin/env python3 """ Merge external CSV / JSON (e.g. Kaggle SMS spam, phishing email exports) into data/scam_dataset.json. You must download datasets yourself and respect their licenses. This tool only maps rows into the env schema (single-turn, difficulty=easy by default) and dedupes by message text. Examples: python scripts/merge_external_datasets.py \\ --input ~/Downloads/sms.csv --text-column v2 --label-column v1 \\ --scam-values spam,1 --legit-values ham,0 python scripts/merge_external_datasets.py \\ --input a.jsonl b.jsonl --format jsonl --json-text message --json-label label python scripts/merge_external_datasets.py --input extra_scenarios.json --format native """ from __future__ import annotations import argparse import csv import hashlib import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent _SCRIPTS = Path(__file__).resolve().parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) if str(_SCRIPTS) not in sys.path: sys.path.insert(0, str(_SCRIPTS)) from validate_dataset import assert_dataset_ok URL_RE = re.compile(r"https?://|www\.|bit\.ly/|\.ru/|\.tk\b|upi\.pay|\.xyz\b", re.I) URGENT_WORDS = ( "urgent", "immediately", "blocked", "expire", "otp", "verify now", "suspend", "last chance", "act now", "click", ) def _norm_text(s: str) -> str: return re.sub(r"\s+", " ", (s or "").strip().lower()) def _hash_id(source_slug: str, text: str) -> str: h = hashlib.sha256(f"{source_slug}:{text}".encode("utf-8")).hexdigest()[:12] return f"ext_{source_slug}_{h}" def _heuristic_urgency(text: str) -> float: low = text.lower() score = 0.45 for w in URGENT_WORDS: if w in low: score += 0.06 if URL_RE.search(text): score += 0.08 return max(0.0, min(1.0, score)) def _link_present(text: str) -> bool: return bool(URL_RE.search(text)) def _parse_values(s: str) -> set[str]: return {x.strip().lower() for x in s.split(",") if x.strip()} def _label_to_truth(raw: str, scam_vals: set[str], legit_vals: set[str]) -> str | None: v = raw.strip().lower() if v in scam_vals: return "scam" if v in legit_vals: return "legitimate" return None def row_from_text( text: str, true_label: str, *, source_slug: str, difficulty: str, channel: str, language: str, tags: list[str], ) -> dict | None: t = (text or "").strip() if len(t) < 3: return None t = t[:4000] return { "id": _hash_id(source_slug, _norm_text(t)), "difficulty": difficulty, "true_label": true_label, "channel": channel, "language": language, "sender_type": "unknown", "message": t, "messages": [t], "link_present": _link_present(t), "urgency_score": _heuristic_urgency(t), "tags": tags + ["external_import", source_slug], } def load_csv_rows( path: Path, text_col: str, label_col: str, scam_vals: set[str], legit_vals: set[str], encoding: str, ) -> list[tuple[str, str]]: out: list[tuple[str, str]] = [] with path.open(newline="", encoding=encoding, errors="replace") as f: reader = csv.DictReader(f) if text_col not in reader.fieldnames or label_col not in reader.fieldnames: raise ValueError(f"{path}: columns missing — have {reader.fieldnames}, need {text_col!r} {label_col!r}") for row in reader: txt = (row.get(text_col) or "").strip() lab = row.get(label_col) or "" truth = _label_to_truth(lab, scam_vals, legit_vals) if truth is None or not txt: continue out.append((txt, truth)) return out def load_jsonl_rows( path: Path, text_key: str, label_key: str, scam_vals: set[str], legit_vals: set[str], ) -> list[tuple[str, str]]: out: list[tuple[str, str]] = [] for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): line = line.strip() if not line: continue obj = json.loads(line) if not isinstance(obj, dict): continue txt = str(obj.get(text_key) or "").strip() lab = str(obj.get(label_key) or "") truth = _label_to_truth(lab, scam_vals, legit_vals) if truth is None or not txt: continue out.append((txt, truth)) return out def load_json_array_rows( path: Path, text_key: str, label_key: str, scam_vals: set[str], legit_vals: set[str], ) -> list[tuple[str, str]]: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, list): raise ValueError(f"{path}: JSON root must be an array") out: list[tuple[str, str]] = [] for obj in data: if not isinstance(obj, dict): continue txt = str(obj.get(text_key) or "").strip() lab = str(obj.get(label_key) or "") truth = _label_to_truth(lab, scam_vals, legit_vals) if truth is None or not txt: continue out.append((txt, truth)) return out def load_native_scenarios(path: Path) -> list[dict]: data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, dict) and "scenarios" in data: data = data["scenarios"] if not isinstance(data, list): raise ValueError(f"{path}: native format expects JSON array of scenario objects") return list(data) def main() -> None: parser = argparse.ArgumentParser(description="Merge external datasets into scam_dataset.json") parser.add_argument("--base", type=Path, default=ROOT / "data" / "scam_dataset.json") parser.add_argument("--output", type=Path, default=ROOT / "data" / "scam_dataset.json") parser.add_argument("--input", type=Path, action="append", dest="inputs", required=True) parser.add_argument( "--format", choices=("auto", "csv", "jsonl", "json_array", "native"), default="auto", ) parser.add_argument("--text-column", default="text", help="CSV column for message body") parser.add_argument("--label-column", default="label", help="CSV column for class label") parser.add_argument("--json-text", default="text", help="JSON/JSONL key for message") parser.add_argument("--json-label", default="label", help="JSON/JSONL key for label") parser.add_argument( "--scam-values", default="spam,scam,1,fraud,phishing,malicious", help="Comma-separated label values mapped to true_label=scam (case-insensitive)", ) parser.add_argument( "--legit-values", default="ham,legitimate,0,benign,ok,good", help="Comma-separated label values mapped to true_label=legitimate", ) parser.add_argument("--difficulty", choices=("easy", "medium", "hard"), default="easy") parser.add_argument("--channel", default="sms", choices=("sms", "email", "whatsapp", "in_app")) parser.add_argument("--language", default="en") parser.add_argument("--encoding", default="utf-8") parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() scam_vals = _parse_values(args.scam_values) legit_vals = _parse_values(args.legit_values) overlap = scam_vals & legit_vals if overlap: print(f"Error: scam and legit value sets overlap: {overlap}", file=sys.stderr) raise SystemExit(1) base_rows: list[dict] = json.loads(args.base.read_text(encoding="utf-8")) seen_text: set[str] = set() for r in base_rows: if r.get("messages") and isinstance(r["messages"], list) and r["messages"]: seen_text.add(_norm_text(str(r["messages"][0]))) existing_ids = {r["id"] for r in base_rows} added = 0 skipped_dup = 0 skipped_fmt = 0 for inp in args.inputs: if not inp.is_file(): print(f"Error: file not found: {inp}", file=sys.stderr) raise SystemExit(1) fmt = args.format if fmt == "auto": suf = inp.suffix.lower() if suf == ".csv": fmt = "csv" elif suf == ".jsonl": fmt = "jsonl" elif suf == ".json": peek = inp.read_text(encoding="utf-8", errors="replace")[:200].lstrip() if peek.startswith("["): fmt = "json_array" else: fmt = "jsonl" else: print(f"Error: cannot infer format for {inp}; set --format", file=sys.stderr) raise SystemExit(1) slug = re.sub(r"[^a-z0-9]+", "_", inp.stem.lower()).strip("_")[:40] or "file" if fmt == "native": for row in load_native_scenarios(inp): if not isinstance(row, dict): skipped_fmt += 1 continue rid = row.get("id") if not rid: skipped_fmt += 1 continue if rid in existing_ids: skipped_dup += 1 continue msgs = row.get("messages") if isinstance(msgs, list) and msgs: nt = _norm_text(str(msgs[0])) else: nt = _norm_text(str(row.get("message") or "")) if not nt: skipped_fmt += 1 continue if nt in seen_text: skipped_dup += 1 continue base_rows.append(row) existing_ids.add(str(rid)) seen_text.add(nt) added += 1 continue pairs: list[tuple[str, str]] if fmt == "csv": pairs = load_csv_rows( inp, args.text_column, args.label_column, scam_vals, legit_vals, args.encoding ) elif fmt == "jsonl": pairs = load_jsonl_rows(inp, args.json_text, args.json_label, scam_vals, legit_vals) elif fmt == "json_array": pairs = load_json_array_rows(inp, args.json_text, args.json_label, scam_vals, legit_vals) else: raise SystemExit(1) for txt, truth in pairs: nt = _norm_text(txt) if nt in seen_text: skipped_dup += 1 continue rec = row_from_text( txt, truth, source_slug=slug, difficulty=args.difficulty, channel=args.channel, language=args.language, tags=[], ) if rec is None: skipped_fmt += 1 continue if rec["id"] in existing_ids: skipped_dup += 1 continue base_rows.append(rec) existing_ids.add(rec["id"]) seen_text.add(nt) added += 1 print( f"Merged: +{added} new scenarios, skipped {skipped_dup} duplicates, skipped {skipped_fmt} bad/short rows. " f"Total {len(base_rows)}." ) if args.dry_run: print("Dry run: not writing output.") return args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(base_rows, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") assert_dataset_ok(base_rows) print(f"OK: validated and wrote {args.output}") if __name__ == "__main__": main()