#!/usr/bin/env python3 """ scripts/clean_prepared_data.py Cleans the outputs in `prepared_data/` using heuristic filters to remove noisy / unwanted examples. Produces: - prepared_data/cleaned_extracted_text.jsonl - prepared_data/cleaned_labeled_dataset.csv - prepared_data/cleaned_instruction_tuning.jsonl - prepared_data/cleaning_report.json Usage examples (run from project root): # Dry-run (no writes), print summary python scripts/clean_prepared_data.py --prepared-dir prepared_data --dry-run # Run and write cleaned outputs with defaults python scripts/clean_prepared_data.py --prepared-dir prepared_data --apply # More aggressive: drop pages with >10 BTC addresses or >30 [URL] markers python scripts/clean_prepared_data.py --prepared-dir prepared_data --apply --url-threshold 30 --btc-threshold 10 The heuristics are intentionally conservative; tweak thresholds as needed. """ from pathlib import Path import argparse import json import re import hashlib from collections import Counter, defaultdict import csv import sys # Regexes BTC_RE = re.compile(r"\b[13][a-km-zA-HJ-NP-Z1-9]{25,34}\b") HEX_RE = re.compile(r"\b0x[a-fA-F0-9]{10,}\b") LONG_HEX_RE = re.compile(r"\b[A-Fa-f0-9]{40,}\b") URL_TOKEN = "[URL]" def sha256_text(s: str) -> str: return hashlib.sha256(s.encode('utf-8', errors='ignore')).hexdigest() def load_jsonl(path: Path): with path.open(encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue try: yield json.loads(line) except Exception: # try to be tolerant to bad lines continue def write_jsonl(path: Path, objs): path.parent.mkdir(parents=True, exist_ok=True) with path.open('w', encoding='utf-8') as f: for o in objs: f.write(json.dumps(o, ensure_ascii=False) + "\n") def read_csv_as_rows(path: Path): with path.open(encoding='utf-8', newline='') as f: reader = csv.DictReader(f) for r in reader: yield r def write_csv(path: Path, rows, fieldnames): path.parent.mkdir(parents=True, exist_ok=True) with path.open('w', encoding='utf-8', newline='') as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() for r in rows: writer.writerow(r) class Cleaner: def __init__(self, url_threshold=20, min_chars=60, btc_threshold=8, hex_threshold=3, repeat_chunk_thresh=3, blacklist=None, blacklist_enabled=False, max_chars=20000): self.url_threshold = int(url_threshold) self.min_chars = int(min_chars) self.btc_threshold = int(btc_threshold) self.hex_threshold = int(hex_threshold) self.repeat_chunk_thresh = int(repeat_chunk_thresh) self.blacklist = set([b.lower() for b in (blacklist or [])]) self.blacklist_enabled = blacklist_enabled self.max_chars = int(max_chars) def _count_repeated_chunks(self, text, chunk_size=60): # simple repeated-chunk heuristic: count duplicate substrings of length chunk_size if len(text) < chunk_size * 2: return 0 chunks = [text[i:i+chunk_size] for i in range(0, len(text)-chunk_size+1, chunk_size)] c = Counter(chunks) # how many chunks repeat more than once repeats = sum(1 for v in c.values() if v > 1) return repeats def is_noisy(self, text: str) -> (bool, str): """Return (is_noisy, reason) using heuristics observed in your dataset.""" if not text or not text.strip(): return True, 'empty' t = text # very short if len(t) < self.min_chars: return True, f'too_short:{len(t)}' # too many [URL] tokens (common in References blocks) url_tokens = t.count(URL_TOKEN) + len(re.findall(r'https?://', t)) + t.count('file://') if url_tokens >= self.url_threshold: return True, f'tool_many_urls:{url_tokens}' # btc address dumps btc_count = len(BTC_RE.findall(t)) if btc_count >= self.btc_threshold: return True, f'btc_dump:{btc_count}' # hex-like tokens hex_count = len(HEX_RE.findall(t)) + len(LONG_HEX_RE.findall(t)) if hex_count >= self.hex_threshold: return True, f'hex_tokens:{hex_count}' # long reference lists by word heuristics if 'compteur de visite' in t.lower() or ('references' in t.lower() and url_tokens > 5): return True, 'references_block' # repeated chunk heuristic repeats = self._count_repeated_chunks(t, chunk_size=80) if repeats >= self.repeat_chunk_thresh: return True, f'repeated_chunks:{repeats}' # blacklist if self.blacklist_enabled: low = t.lower() for b in self.blacklist: if b and b in low: return True, f'blacklist_match:{b}' # too long single example: truncate instead of drop if len(t) > self.max_chars: # not noisy per se, but will be truncated by the cleaner return False, 'too_long_truncate' return False, '' def clean_text(self, text: str) -> str: # basic post-processing: collapse whitespace and truncate to max_chars s = re.sub(r"\s+", " ", text).strip() if len(s) > self.max_chars: s = s[:self.max_chars] + "\n\n[TRUNCATED]" return s def main(): p = argparse.ArgumentParser() p.add_argument('--prepared-dir', type=Path, default=Path('prepared_data')) p.add_argument('--apply', action='store_true', help='Write cleaned outputs. Without this flag runs a dry-run and prints summary') p.add_argument('--url-threshold', type=int, default=20) p.add_argument('--min-chars', type=int, default=60) p.add_argument('--btc-threshold', type=int, default=8) p.add_argument('--hex-threshold', type=int, default=3) p.add_argument('--repeat-chunk-thresh', type=int, default=3) p.add_argument('--blacklist', type=str, default='', help='Comma-separated blacklist terms to drop (optional)') p.add_argument('--enable-blacklist', action='store_true', help='Enable blacklist matching') p.add_argument('--max-chars', type=int, default=20000) args = p.parse_args() prepared = Path(args.prepared_dir) if not prepared.exists(): print('prepared_data dir not found:', prepared) sys.exit(2) extracted_path = prepared / 'extracted_text.jsonl' labeled_csv = prepared / 'labeled_dataset.csv' instruction_j = prepared / 'instruction_tuning.jsonl' if not extracted_path.exists(): print('extracted_text.jsonl missing at', extracted_path) sys.exit(2) cleaner = Cleaner( url_threshold=args.url_threshold, min_chars=args.min_chars, btc_threshold=args.btc_threshold, hex_threshold=args.hex_threshold, repeat_chunk_thresh=args.repeat_chunk_thresh, blacklist=[t.strip() for t in args.blacklist.split(',')] if args.blacklist else None, blacklist_enabled=args.enable_blacklist, max_chars=args.max_chars, ) cleaned_objs = [] stats = defaultdict(int) reasons = Counter() kept_sources = set() seen_hashes = set() # iterate and filter for obj in load_jsonl(extracted_path): text = obj.get('text','') is_noise, reason = cleaner.is_noisy(text) if is_noise: stats['dropped'] += 1 reasons[reason] += 1 continue # clean text new_text = cleaner.clean_text(text) # dedupe by text hash h = sha256_text(new_text)[:32] if h in seen_hashes: stats['duplicate'] += 1 reasons['duplicate'] += 1 continue seen_hashes.add(h) # update object obj['text'] = new_text cleaned_objs.append(obj) kept_sources.add(obj.get('source_path') or obj.get('filename') or '') stats['kept'] += 1 stats['total'] = stats.get('kept',0) + stats.get('dropped',0) + stats.get('duplicate',0) # prepare output paths out_extracted = prepared / 'cleaned_extracted_text.jsonl' out_csv = prepared / 'cleaned_labeled_dataset.csv' out_instr = prepared / 'cleaned_instruction_tuning.jsonl' report_path = prepared / 'cleaning_report.json' # dry-run: print summary if not args.apply: print('DRY RUN - no files written') print('Prepared dir:', prepared) print('Total input examples:', stats['total']) print('Kept:', stats['kept'], 'Dropped:', stats['dropped'], 'Duplicates:', stats['duplicate']) print('Top drop reasons:') for r, c in reasons.most_common(12): print(' ', r, c) print('\nTo write cleaned outputs add --apply') return # write cleaned extracted JSONL write_jsonl(out_extracted, cleaned_objs) # filter labeled CSV to keep only rows that are in cleaned set if labeled_csv.exists(): kept_rows = [] fieldnames = None for row in read_csv_as_rows(labeled_csv): # match by source_path sp = row.get('source_path') or '' if sp in kept_sources: kept_rows.append(row) else: # there may be entries where source_path is just a filename; try filename match fn = row.get('source_path','') if kept_rows: fieldnames = list(kept_rows[0].keys()) write_csv(out_csv, kept_rows, fieldnames) else: # fallback: regenerate labeled CSV minimally from cleaned_objs using Unknown label fieldnames = ['source_path','text','label'] regen = [] for o in cleaned_objs: regen.append({'source_path': o.get('source_path',''), 'text': o.get('text',''), 'label': 'Unknown'}) write_csv(out_csv, regen, fieldnames) else: # No labeled CSV original: create minimal one fieldnames = ['source_path','text','label'] regen = [] for o in cleaned_objs: regen.append({'source_path': o.get('source_path',''), 'text': o.get('text',''), 'label': 'Unknown'}) write_csv(out_csv, regen, fieldnames) # regenerate instruction JSONL from cleaned CSV # Use same basic prompt as original pipeline to keep format consistent PROMPT = ( """Below is an instruction that describes a task, paired with an input that provides further context. \nWrite a response that appropriately completes the request. \nBefore answering, think carefully about the question ensure an accurate response.\n\n### Instruction:\nYou are an expert in cybersecurity and threat intelligence.\nYour role is to provide precise classification of threats from the description provided by the user.\n\n### Description : \n{} \n\n### Response:\n{}""" ) written = 0 with out_instr.open('w', encoding='utf-8') as outf: # read from cleaned CSV for row in read_csv_as_rows(out_csv): text = row.get('text','') label = row.get('label','Unknown') prompt = PROMPT.format(text, '') combined = prompt + "\n" + label + " " outf.write(json.dumps({'text': combined, 'label': label, 'source': row.get('source_path','')}, ensure_ascii=False) + "\n") written += 1 # write report report = { 'input_total': stats['total'], 'kept': stats['kept'], 'dropped': stats['dropped'], 'duplicates': stats['duplicate'], 'drop_reasons': dict(reasons.most_common()), 'out_extracted': str(out_extracted), 'out_csv': str(out_csv), 'out_instruction': str(out_instr), } with report_path.open('w', encoding='utf-8') as f: json.dump(report, f, indent=2) print('Wrote cleaned extracted JSONL ->', out_extracted) print('Wrote cleaned labeled CSV ->', out_csv) print('Wrote cleaned instruction JSONL ->', out_instr) print('Wrote cleaning report ->', report_path) print('Summary: kept', stats['kept'], 'dropped', stats['dropped'], 'duplicates', stats['duplicate']) if __name__ == '__main__': main()