| |
| """ |
| AI Text Rewriting — Evasion Detection Pipeline |
| |
| Rewrites AI-generated text into natural, human-like prose that evades |
| AI text detectors (Fast-DetectGPT, Binoculars, GPTZero, Pangram). |
| |
| Usage: |
| python rewrite_text.py "Your AI-generated text here" |
| python rewrite_text.py --file data/bitcoin_text.txt |
| python rewrite_text.py --file input.txt --verify --stats |
| python rewrite_text.py --help |
| |
| How it works: |
| 1. Sends text to Modal GPU (T4, ~0.60/h) |
| 2. Qwen2.5-1.5B-Instruct rewrites with optimized sampling |
| 3. Verifies: coherence, length similarity, no artifacts, no hallucinations |
| 4. Computes dispersion statistics (TTR, burstiness, word freq variance) |
| 5. Saves result + uploads to HuggingFace |
| |
| Requirements: Modal CLI installed and configured |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import re |
| import subprocess |
| import sys |
| import tempfile |
| import time |
| from pathlib import Path |
|
|
| SCRIPT_DIR = Path(__file__).parent |
| MODAL_APP = SCRIPT_DIR / "src" / "modal_app_rewrite.py" |
|
|
|
|
| def run_rewrite(text: str, gpu: str = "T4", verify: bool = True, |
| dry_run: bool = False) -> dict: |
| """Dispatch rewrite to Modal GPU.""" |
| print(f"\n[Rewrite] Dispatching to Modal {gpu} GPU...") |
| print(f"[Rewrite] Input: {len(text.split())} words, {len(text)} chars") |
|
|
| with tempfile.NamedTemporaryFile( |
| mode="w", suffix=".txt", delete=False, encoding="utf-8" |
| ) as f: |
| f.write(text) |
| temp_path = f.name |
|
|
| try: |
| cmd = [ |
| "modal", "run", "-q", |
| str(MODAL_APP), |
| "--text-file", temp_path, |
| "--gpu", gpu, |
| ] |
| if verify: |
| cmd.append("--verify") |
| if dry_run: |
| cmd.append("--dry-run") |
|
|
| start = time.time() |
| result = subprocess.run( |
| cmd, |
| capture_output=True, |
| text=True, |
| cwd=str(SCRIPT_DIR), |
| timeout=600, |
| env={**os.environ, "PYTHONIOENCODING": "utf-8"}, |
| ) |
|
|
| elapsed = time.time() - start |
|
|
| out_file = SCRIPT_DIR / "output" / "rewrite_result.json" |
| if out_file.exists(): |
| with open(out_file, "r", encoding="utf-8") as f: |
| data = json.load(f) |
| if data.get("status") == "completed": |
| data["_dispatch_time_s"] = round(elapsed, 1) |
| return data |
|
|
| if result.returncode != 0 and "UnicodeEncodeError" not in result.stderr: |
| return {"status": "error", "message": result.stderr[-500:]} |
|
|
| return {"status": "error", "message": "No output file"} |
|
|
| except subprocess.TimeoutExpired: |
| return {"status": "error", "message": "Timeout (10 min)"} |
| finally: |
| os.unlink(temp_path) |
|
|
|
|
| def verify_rewrite(original: str, rewritten: str) -> dict: |
| """Verify rewrite quality.""" |
| orig_w = len(original.split()) |
| rew_w = len(rewritten.split()) |
| ratio = rew_w / max(orig_w, 1) |
|
|
| issues = [] |
| ok = [] |
|
|
| |
| if ratio < 0.4: |
| issues.append(f"Too short: {rew_w}w vs {orig_w}w (ratio={ratio:.2f})") |
| elif ratio > 2.5: |
| issues.append(f"Too long: {rew_w}w vs {orig_w}w (ratio={ratio:.2f})") |
| else: |
| ok.append(f"Length: {orig_w}w -> {rew_w}w (ratio={ratio:.2f})") |
|
|
| |
| artifacts = ["###", "Here's a", "Let me know", "Feel free", "I hope this", |
| "In conclusion", "To summarize", "Note:", "Please note"] |
| found_artifacts = [a for a in artifacts if a.lower() in rewritten.lower()] |
| if found_artifacts: |
| issues.append(f"Artifacts: {found_artifacts}") |
| else: |
| ok.append("No artifacts detected") |
|
|
| |
| orig_nums = set(re.findall(r'\b\d+\b', original)) |
| rew_nums = set(re.findall(r'\b\d+\b', rewritten)) |
| if orig_nums: |
| missing = orig_nums - rew_nums |
| if len(missing) / len(orig_nums) > 0.5: |
| issues.append(f"Missing numbers: {missing}") |
| else: |
| ok.append(f"Numbers preserved: {len(orig_nums - missing)}/{len(orig_nums)}") |
|
|
| |
| orig_proper = set(re.findall(r'\b[A-Z][a-z]{2,}\b', original)) |
| rew_lower = rewritten.lower() |
| missing_proper = [p for p in orig_proper if p.lower() not in rew_lower] |
| if len(missing_proper) > len(orig_proper) * 0.5: |
| issues.append(f"Missing key terms: {missing_proper[:5]}") |
| else: |
| ok.append(f"Key terms preserved: {len(orig_proper) - len(missing_proper)}/{len(orig_proper)}") |
|
|
| |
| sentences = [s.strip() for s in re.split(r'[.!?]+', rewritten) if len(s.strip()) > 10] |
| if len(sentences) >= 3: |
| unique = len(set(s[:30].lower() for s in sentences)) |
| if unique < len(sentences) * 0.5: |
| issues.append(f"Repetitive: {unique} unique / {len(sentences)} sentences") |
| else: |
| ok.append(f"Varied sentences: {unique} unique / {len(sentences)}") |
|
|
| |
| orig_set = set(original.lower().split()) |
| rew_set = set(rewritten.lower().split()) |
| new_words = rew_set - orig_set |
| if len(new_words) > len(rew_set) * 0.7: |
| issues.append(f"Too many new words: {len(new_words)}/{len(rew_set)}") |
| else: |
| ok.append(f"Vocabulary overlap: {len(rew_set - new_words)}/{len(rew_set)} original") |
|
|
| return { |
| "passed": len(issues) == 0, |
| "ok": ok, |
| "issues": issues, |
| "length_ratio": round(ratio, 2), |
| "original_words": orig_w, |
| "rewritten_words": rew_w, |
| } |
|
|
|
|
| def compute_stats(original: str, rewritten: str) -> dict: |
| """Compute dispersion statistics.""" |
| sys.path.insert(0, str(SCRIPT_DIR / "src")) |
| from eval_statistical import compute_stats as cs, compute_dispersion_score as cds |
|
|
| os_, rs = cs(original), cs(rewritten) |
| od, rd = cds(os_), cds(rs) |
|
|
| return { |
| "original": {"words": os_.num_words, "ttr": round(os_.type_token_ratio, 3), |
| "sent_cv": round(os_.sentence_len_cv, 3), |
| "word_freq_std": round(os_.std_word_freq, 2), |
| "flesch": round(os_.readability_flesch, 0), |
| "human_likeness": od["overall_human_likeness"]}, |
| "rewritten": {"words": rs.num_words, "ttr": round(rs.type_token_ratio, 3), |
| "sent_cv": round(rs.sentence_len_cv, 3), |
| "word_freq_std": round(rs.std_word_freq, 2), |
| "flesch": round(rs.readability_flesch, 0), |
| "human_likeness": rd["overall_human_likeness"]}, |
| "deltas": {k: round(rd[k] - od[k], 3) for k in od}, |
| } |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser( |
| description="Rewrite AI text to evade detectors (Modal GPU)", |
| epilog="Example: python rewrite_text.py 'Your AI text here' --verify --stats" |
| ) |
| parser.add_argument("text", nargs="?", help="Text to rewrite (or use --file)") |
| parser.add_argument("--file", "-f", help="File containing text") |
| parser.add_argument("--output", "-o", default="output/rewrite_result.json") |
| parser.add_argument("--gpu", default="T4") |
| parser.add_argument("--dry-run", action="store_true") |
| parser.add_argument("--no-verify", action="store_true") |
| parser.add_argument("--stats", action="store_true") |
| parser.add_argument("--upload", action="store_true", help="Upload to HF after rewrite") |
| args = parser.parse_args() |
|
|
| |
| if args.file: |
| with open(args.file, "r", encoding="utf-8") as f: |
| text = f.read().strip() |
| elif args.text: |
| text = args.text |
| else: |
| print("ERROR: Provide text or use --file") |
| sys.exit(1) |
|
|
| if not text.strip(): |
| print("ERROR: Empty text") |
| sys.exit(1) |
|
|
| print("=" * 60) |
| print(" AI Text Rewriting — Evasion Detection") |
| print("=" * 60) |
| print(f"\n[Original] {len(text.split())} words, {len(text)} chars:") |
| print(text[:300] + ("..." if len(text) > 300 else "")) |
|
|
| |
| result = run_rewrite(text, gpu=args.gpu, verify=not args.no_verify, |
| dry_run=args.dry_run) |
|
|
| if result.get("status") != "completed": |
| print(f"\nERROR: {result.get('message', 'Unknown error')}") |
| sys.exit(1) |
|
|
| r = result["results"][0] |
| rewritten = r["rewritten"] |
|
|
| print(f"\n[Rewritten] {len(rewritten.split())} words, {r.get('tokens', '?')} tokens, " |
| f"{r.get('elapsed_seconds', '?')}s:") |
| print(rewritten) |
|
|
| |
| if not args.no_verify: |
| print(f"\n{'=' * 60}") |
| print(" QUALITY VERIFICATION") |
| print("=" * 60) |
| v = r.get("verification") or verify_rewrite(text, rewritten) |
|
|
| for check in v.get("ok", []): |
| print(f" OK {check}") |
| for issue in v.get("issues", []): |
| print(f" FAIL {issue}") |
|
|
| status = "ALL CHECKS PASSED" if v["passed"] else "SOME CHECKS FAILED" |
| print(f"\n => {status}") |
| print(f" => Length: {v['original_words']}w -> {v['rewritten_words']}w " |
| f"(ratio: {v['length_ratio']:.2f})") |
|
|
| |
| if args.stats: |
| print(f"\n{'=' * 60}") |
| print(" DISPERSION STATISTICS") |
| print("=" * 60) |
| s = compute_stats(text, rewritten) |
| print(f" {'Metric':<25} {'Original':>10} {'Rewritten':>10} {'Delta':>10}") |
| print(f" {'-'*55}") |
| for metric in ["words", "ttr", "sent_cv", "word_freq_std", "flesch", "human_likeness"]: |
| o_val = s["original"][metric] |
| r_val = s["rewritten"][metric] |
| d_val = s["deltas"][metric] |
| print(f" {metric:<25} {str(o_val):>10} {str(r_val):>10} {d_val:>+10}") |
|
|
| |
| output = { |
| "original": text, |
| "rewritten": rewritten, |
| "model": r.get("model", "Qwen/Qwen2.5-1.5B-Instruct"), |
| "method": "direct-generation-optimized-sampling", |
| "config": result.get("config", {}), |
| "tokens": r.get("tokens", 0), |
| "elapsed_seconds": r.get("elapsed_seconds", 0), |
| } |
| if not args.no_verify: |
| output["verification"] = v |
| if args.stats: |
| output["statistics"] = s |
|
|
| os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) |
| with open(args.output, "w", encoding="utf-8") as f: |
| json.dump(output, f, indent=2, ensure_ascii=False) |
|
|
| print(f"\n[Save] {args.output}") |
| print(f"[Save] View: https://huggingface.co/simonlesaumon/evasion-detection-artifacts") |
|
|
| |
| if args.upload: |
| import subprocess as sp |
| sp.run(["hf", "upload", "simonlesaumon/evasion-detection-artifacts", |
| args.output, f"results/rewrite_{int(time.time())}.json"], |
| cwd=str(SCRIPT_DIR)) |
|
|
| return 0 if (v.get("passed", True) if not args.no_verify else True) else 1 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|