Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """Export kink title clusters for offline review and normalization benchmarking. | |
| Emits JSONL lines (one JSON object per line) for either: | |
| - ``fingerprint``: same rules as ``scripts/find_name_duplicate_clusters.py`` / | |
| ``frontend/name-fingerprint.js`` (keep in sync when tuning). | |
| - ``merge_signature``: same rules as ``backend.kink_merge.merge_signature`` / | |
| ``propose_merge_clusters`` (FMF / threesome token variants). | |
| Usage: | |
| python scripts/export_normalization_clusters.py --db data/store_slim.db --out /tmp/clusters.jsonl --mode fingerprint | |
| python scripts/export_normalization_clusters.py --db data/store_slim.db --out /tmp/merge_sig.jsonl --mode merge_signature --min-size 2 | |
| For **pair-level** labeling (merge vs alias vs reject), prefer | |
| ``scripts/source_normalization_benchmark_pairs.py`` (tiers documented in ``backend/benchmark_pair_sources.py``). | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import random | |
| import re | |
| import sqlite3 | |
| from collections import defaultdict | |
| from pathlib import Path | |
| from backend.kink_merge import merge_graph_eligible, merge_signature | |
| def fingerprint_play_title(raw: str) -> str: | |
| s = (raw or "").strip().lower() | |
| if not s: | |
| return "" | |
| s = re.sub(r"[\u2018\u2019\u201c\u201d\u0060\u00b4]", "'", s) | |
| s = re.sub(r"\s+", " ", s) | |
| s = re.sub(r"\bgood\s+morning\s+", "goodmorning ", s) | |
| s = re.sub(r"\bdeep\s*throating\b", "deepthroat", s) | |
| s = re.sub(r"\bdeepthroating\b", "deepthroat", s) | |
| s = re.sub(r"\bdeep\s*throat\b", "deepthroat", s) | |
| s = re.sub(r"\bblow\s+jobs\b", "blowjob", s, flags=re.I) | |
| s = re.sub(r"\bblow\s+job\b", "blowjob", s, flags=re.I) | |
| s = re.sub(r"\bblowjobs\b", "blowjob", s) | |
| s = re.sub(r"\bstrap\s+ons\b", "strapon", s, flags=re.I) | |
| s = re.sub(r"\bstrap\s+on\b", "strapon", s, flags=re.I) | |
| s = re.sub(r"\bstrapons\b", "strapon", s) | |
| s = re.sub(r"[^\w\s]", " ", s) | |
| s = re.sub(r"\s+", " ", s).strip() | |
| s = re.sub(r"\s+", "", s) | |
| return s | |
| def main() -> int: | |
| ap = argparse.ArgumentParser(description=__doc__) | |
| ap.add_argument("--db", type=Path, default=Path("data/store_slim.db")) | |
| ap.add_argument("--out", type=Path, required=True, help="output JSONL path") | |
| ap.add_argument("--mode", choices=("fingerprint", "merge_signature", "both"), default="fingerprint") | |
| ap.add_argument("--min-size", type=int, default=2, help="minimum cluster size to emit") | |
| ap.add_argument("--max-clusters", type=int, default=0, help="cap clusters written (0 = no cap)") | |
| ap.add_argument("--sample-random", type=int, default=0, help="if set, shuffle clusters then take first N after sort") | |
| args = ap.parse_args() | |
| db = args.db.resolve() | |
| if not db.is_file(): | |
| raise SystemExit(f"database not found: {db}") | |
| conn = sqlite3.connect(db) | |
| conn.row_factory = sqlite3.Row | |
| rows = conn.execute( | |
| """ | |
| SELECT k.id, k.name, k.cluster, COALESCE(m.popularity, 0.0) AS pop | |
| FROM kink k | |
| LEFT JOIN fetlifekinkmeta m ON m.kink_id = k.id | |
| """, | |
| ).fetchall() | |
| conn.close() | |
| def emit_fingerprint() -> list[dict[str, object]]: | |
| buckets: dict[str, list[dict[str, object]]] = defaultdict(list) | |
| for r in rows: | |
| fp = fingerprint_play_title(str(r["name"])) | |
| if len(fp) < 4: | |
| continue | |
| buckets[fp].append( | |
| { | |
| "kink_id": str(r["id"]), | |
| "name": str(r["name"]), | |
| "cluster": str(r["cluster"]), | |
| "popularity": float(r["pop"] or 0.0), | |
| }, | |
| ) | |
| out = [] | |
| for key, members in buckets.items(): | |
| if len(members) < args.min_size: | |
| continue | |
| out.append({"cluster_key": key, "members": members}) | |
| out.sort(key=lambda c: (-len(c["members"]), str(c["cluster_key"]))) | |
| return out | |
| def emit_merge_sig() -> list[dict[str, object]]: | |
| buckets: dict[tuple[str, str], list[dict[str, object]]] = defaultdict(list) | |
| for r in rows: | |
| if not merge_graph_eligible(str(r["name"])): | |
| continue | |
| sig = merge_signature(str(r["name"])) | |
| buckets[(str(r["cluster"]), sig)].append( | |
| { | |
| "kink_id": str(r["id"]), | |
| "name": str(r["name"]), | |
| "cluster": str(r["cluster"]), | |
| "popularity": float(r["pop"] or 0.0), | |
| }, | |
| ) | |
| out = [] | |
| for (cluster, sig), members in sorted(buckets.items(), key=lambda x: (-len(x[1]), x[0][0], x[0][1])): | |
| if len(members) < args.min_size: | |
| continue | |
| out.append({"cluster_key": f"{cluster}\t{sig}", "cluster": cluster, "signature": sig, "members": members}) | |
| return out | |
| clusters_out: list[tuple[str, list[dict[str, object]]]] = [] | |
| if args.mode in ("fingerprint", "both"): | |
| for c in emit_fingerprint(): | |
| clusters_out.append(("fingerprint", c)) | |
| if args.mode in ("merge_signature", "both"): | |
| for c in emit_merge_sig(): | |
| clusters_out.append(("merge_signature", c)) | |
| if args.sample_random > 0 and len(clusters_out) > args.sample_random: | |
| rng = random.Random(42) | |
| rng.shuffle(clusters_out) | |
| clusters_out = clusters_out[: args.sample_random] | |
| if args.max_clusters > 0: | |
| clusters_out = clusters_out[: args.max_clusters] | |
| args.out.parent.mkdir(parents=True, exist_ok=True) | |
| with args.out.open("w", encoding="utf-8") as fh: | |
| for mode, cluster in clusters_out: | |
| rec = {"mode": mode, **cluster} | |
| fh.write(json.dumps(rec, ensure_ascii=False) + "\n") | |
| print(json.dumps({"wrote": str(args.out), "lines": len(clusters_out), "mode": args.mode}, indent=2)) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |