"""Phase 2.3 — clean every raw bucket → data/cpt_clean/.jsonl Pipeline order (plan §2.3), applied per doc, then a global cross-bucket dedup: 1. ftfy unicode fix (NFC) 2. fastText language id — drop if top lang != ru or p(ru) < 0.65 3. mixed-script rejection — drop docs with homoglyph-contaminated words 4. boilerplate/length — strip HTML, collapse whitespace, drop <200 or >50k chars, drop docs with >30% non-letter chars 5. MinHash-LSH near-dup removal across ALL buckets (the biggest quality lever) 6. light PII scrub (phones/emails) — logs bucket has real data Usage: python corpus_clean.py # all buckets present in data/cpt_raw/ python corpus_clean.py wiki synth # specific buckets """ import sys import re import json import html import ftfy from datasketch import MinHash, MinHashLSH from corpus_common import RAW, CLEAN, has_mixed_script, lang_prob MIN_CHARS, MAX_CHARS = 200, 50_000 NON_LETTER_MAX = 0.30 LID_MIN = 0.65 LSH_THRESHOLD = 0.8 NUM_PERM = 128 _TAG = re.compile(r"<[^>]+>") _WS = re.compile(r"[ \t ]+") _NL = re.compile(r"\n{3,}") _LETTER = re.compile(r"[^\W\d_]", re.UNICODE) _EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+") _PHONE = re.compile(r"(? str: text = ftfy.fix_text(text) text = html.unescape(text) text = _TAG.sub(" ", text) text = _WS.sub(" ", text) text = _NL.sub("\n\n", text) return text.strip() def non_letter_ratio(text: str) -> float: letters = len(_LETTER.findall(text)) return 1.0 - (letters / len(text)) if text else 1.0 def scrub_pii(text: str) -> str: text = _EMAIL.sub("", text) text = _PHONE.sub("", text) return text def doc_minhash(text: str) -> MinHash: m = MinHash(num_perm=NUM_PERM) for tok in text.lower().split(): m.update(tok.encode("utf-8")) return m def clean_bucket(bucket: str, lsh: MinHashLSH, seen: dict) -> int: src = RAW / bucket / "shard-000.jsonl" if not src.exists(): print(f"[{bucket}] no raw shard at {src}; skip") return 0 out = CLEAN / f"{bucket}.jsonl" kept = dropped = 0 with open(src, encoding="utf-8") as fin, open(out, "w", encoding="utf-8") as fout: for i, line in enumerate(fin): try: text = json.loads(line)["text"] except (json.JSONDecodeError, KeyError): dropped += 1 continue text = normalize(text) # 4. length / boilerplate if not (MIN_CHARS <= len(text) <= MAX_CHARS) or non_letter_ratio(text) > NON_LETTER_MAX: dropped += 1 continue # 3. mixed script if has_mixed_script(text): dropped += 1 continue # 2. language id try: lang, p = lang_prob(text) if lang != "ru" or p < LID_MIN: dropped += 1 continue except FileNotFoundError: raise SystemExit("lid.176.bin missing — see corpus_common.LID_PATH") # 5. dedup m = doc_minhash(text) if lsh.query(m): dropped += 1 continue key = f"{bucket}:{i}" lsh.insert(key, m) seen[key] = True # 6. pii text = scrub_pii(text) fout.write(json.dumps({"text": text}, ensure_ascii=False) + "\n") kept += 1 if (kept + dropped) % 5000 == 0: print(f"[{bucket}] kept {kept} dropped {dropped}") print(f"[{bucket}] DONE kept {kept} dropped {dropped} -> {out}") return kept def main(): buckets = sys.argv[1:] or [p.name for p in sorted(RAW.iterdir()) if p.is_dir()] # one shared LSH across all buckets = global dedup (plan §2.3 rule 5) lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=NUM_PERM) seen = {} total = 0 for b in buckets: total += clean_bucket(b, lsh, seen) print(f"[clean] TOTAL kept {total} docs across {len(buckets)} buckets") if __name__ == "__main__": main()