my-env / scripts /merge_external_datasets.py
Maheswar01's picture
Deploy OpenEnv to Space
080a295
Raw
History Blame Contribute Delete
11.6 kB
#!/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()