Files
unredact/scripts/build_associates.py
T
Alex-GilbertandClaude Opus 4.6 2268e8549e feat: add separate V (victim) badge independent of associate tiers
Victim names are stored as a flat set of lowercased name variants
with no person details — the V badge appears but is not clickable,
protecting victim identities. A result can show both T1/T2/T3 and V
badges simultaneously.

- 15 known victims from public court records → 43 name variants
- Enabler/perpetrator categories promoted to T2
- Purple V badge with pointer-events disabled
- Victim matching uses prefix/suffix combination like associate matching

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-16 10:31:38 -06:00

297 lines
9.3 KiB
Python

"""Build the associates.json lookup file from rhowardstone/Epstein-research-data.
Usage:
python scripts/build_associates.py [--data-dir PATH]
Downloads persons_registry.json and knowledge_graph_relationships.json if not
present, then processes them into unredact/data/associates.json.
"""
import argparse
import json
import re
from collections import defaultdict
from pathlib import Path
OUTPUT_PATH = Path(__file__).parent.parent / "unredact" / "data" / "associates.json"
# Common nickname mappings (canonical → [nicknames])
NICKNAMES = {
"jeffrey": ["jeff"],
"william": ["bill", "will", "willy", "billy"],
"richard": ["rick", "dick", "rich"],
"robert": ["rob", "bob", "bobby"],
"james": ["jim", "jimmy", "jamie"],
"michael": ["mike", "mikey"],
"joseph": ["joe", "joey"],
"thomas": ["tom", "tommy"],
"charles": ["charlie", "chuck"],
"christopher": ["chris"],
"daniel": ["dan", "danny"],
"matthew": ["matt"],
"anthony": ["tony"],
"donald": ["don", "donny"],
"steven": ["steve"],
"stephen": ["steve"],
"edward": ["ed", "eddie", "ted"],
"benjamin": ["ben"],
"samuel": ["sam", "sammy"],
"alexander": ["alex"],
"nicholas": ["nick"],
"jonathan": ["jon"],
"timothy": ["tim", "timmy"],
"andrew": ["andy", "drew"],
"lawrence": ["larry"],
"raymond": ["ray"],
"gerald": ["gerry", "jerry"],
"kenneth": ["ken", "kenny"],
"elizabeth": ["liz", "beth", "lizzy"],
"katherine": ["kate", "kathy", "katie"],
"catherine": ["kate", "cathy", "katie"],
"margaret": ["maggie", "meg", "peggy"],
"jennifer": ["jen", "jenny"],
"patricia": ["pat", "patty"],
"virginia": ["ginny"],
"victoria": ["vicky", "tori"],
"deborah": ["deb", "debbie"],
"dorothy": ["dot", "dotty"],
"suzanne": ["sue", "suzy"],
"alexandra": ["alex"],
}
# Tier 2 categories (staff/financial get auto-promoted)
TIER2_CATEGORIES = {"staff", "financial"}
# Known victims from public court records and reporting (Giuffre v. Maxwell, etc.)
# Stored as a separate name set — no person details exposed in the UI.
KNOWN_VICTIMS = {
"Virginia Giuffre", "VIRGINIA ROBERTS", "Virginia Roberts",
"Courtney Wild",
"Sarah Ransome",
"Maria Farmer", "Annie Farmer",
"Chauntae Davies",
"Johanna Sjoberg",
"Michelle Licata",
"Carolyn Andriano",
"Priscilla Doe",
"Teresa Helm",
"Anouska De Georgiou",
"Haley Robson",
}
def _compute_tiers(registry: list[dict], relationships: list[dict]) -> dict[str, int]:
"""Compute tier for each person name.
Returns: {canonical_name: tier}
"""
# Count relationships and check for traveled_with
traveled = set()
rel_counts: dict[str, int] = defaultdict(int)
for rel in relationships:
src = rel.get("source_name", rel.get("source", ""))
tgt = rel.get("target_name", rel.get("target", ""))
rtype = rel.get("relationship_type", "")
rel_counts[src] += 1
rel_counts[tgt] += 1
if rtype == "traveled_with":
traveled.add(src)
traveled.add(tgt)
tiers = {}
for person in registry:
name = person["name"]
category = person.get("category", "other")
if name in traveled:
tiers[name] = 1
elif category in TIER2_CATEGORIES or category in {"enabler", "perpetrator"} or rel_counts.get(name, 0) >= 3:
tiers[name] = 2
else:
tiers[name] = 3
return tiers
def _add_name_entries(
names: dict[str, list],
text: str,
person_id: str,
tier: int,
match_type: str,
):
"""Add a lookup entry for a name string (lowercased)."""
key = text.lower().strip()
if not key or len(key) < 2:
return
if key not in names:
names[key] = []
# Avoid duplicate entries for same person + match_type
if not any(e["person_id"] == person_id and e["match_type"] == match_type for e in names[key]):
names[key].append({"person_id": person_id, "match_type": match_type, "tier": tier})
def _split_name(full_name: str) -> tuple[str, str]:
"""Split a full name into (first, last). Returns ('', '') if can't split."""
parts = full_name.strip().split()
if len(parts) < 2:
return ("", "")
return (parts[0], parts[-1])
def _generate_lookups_for_name(
names: dict[str, list],
full_name: str,
person_id: str,
tier: int,
):
"""Generate all lookup entries for a single name string."""
# Full name
_add_name_entries(names, full_name, person_id, tier, "full")
first, last = _split_name(full_name)
if first and last:
# First and last separately
_add_name_entries(names, first, person_id, tier, "first")
_add_name_entries(names, last, person_id, tier, "last")
# Initials + last
initial = first[0]
_add_name_entries(names, f"{initial}. {last}", person_id, tier, "initial_last")
_add_name_entries(names, f"{initial} {last}", person_id, tier, "initial_last")
# Nicknames
first_lower = first.lower()
if first_lower in NICKNAMES:
for nick in NICKNAMES[first_lower]:
_add_name_entries(names, nick, person_id, tier, "nickname")
_add_name_entries(names, f"{nick} {last}", person_id, tier, "nickname_full")
def _build_victim_names() -> set[str]:
"""Build a flat set of lowercased victim name variants for matching.
No person details — just strings to check against. This keeps victim
identities private in the UI (the V badge shows but is not clickable).
"""
victim_names: set[str] = set()
for full_name in KNOWN_VICTIMS:
low = full_name.lower().strip()
if len(low) < 2:
continue
victim_names.add(low)
parts = low.split()
if len(parts) >= 2:
first, last = parts[0], parts[-1]
victim_names.add(first)
victim_names.add(last)
# Nicknames
if first in NICKNAMES:
for nick in NICKNAMES[first]:
victim_names.add(nick)
victim_names.add(f"{nick} {last}")
return victim_names
def process_associates(
registry: list[dict],
relationships: list[dict],
) -> dict:
"""Process raw registry + relationships into the associates lookup format.
Returns: {"names": {str: [...]}, "persons": {str: {...}}, "victim_names": [str, ...]}
"""
tiers = _compute_tiers(registry, relationships)
names: dict[str, list] = {}
persons: dict[str, dict] = {}
pid_counter = 0
for person in registry:
canonical_name = person["name"]
# Skip redaction placeholders and parsing artifacts from upstream data
if re.search(r'[()]', canonical_name) or len(canonical_name) < 3:
continue
person_id = f"p{pid_counter:04d}"
pid_counter += 1
tier = tiers.get(canonical_name, 3)
category = person.get("category", "other")
persons[person_id] = {
"name": canonical_name,
"tier": tier,
"category": category,
}
# Generate lookups for canonical name
_generate_lookups_for_name(names, canonical_name, person_id, tier)
# Generate lookups for each alias
for alias in person.get("aliases", []):
_generate_lookups_for_name(names, alias, person_id, tier)
victim_names = sorted(_build_victim_names())
return {"names": names, "persons": persons, "victim_names": victim_names}
def download_data(data_dir: Path) -> tuple[list, list]:
"""Download the rhowardstone data files if not already present.
Returns: (registry, relationships)
"""
import urllib.request
base_url = "https://raw.githubusercontent.com/rhowardstone/Epstein-research-data/main"
registry_path = data_dir / "persons_registry.json"
rels_path = data_dir / "knowledge_graph_relationships.json"
for filename, path in [("persons_registry.json", registry_path), ("knowledge_graph_relationships.json", rels_path)]:
if not path.exists():
url = f"{base_url}/{filename}"
print(f"Downloading {url}...")
urllib.request.urlretrieve(url, path)
print(f" -> saved to {path}")
registry = json.loads(registry_path.read_text())
relationships = json.loads(rels_path.read_text())
return registry, relationships
def main():
parser = argparse.ArgumentParser(description="Build associates.json from Epstein research data")
parser.add_argument("--data-dir", type=Path, default=Path(__file__).parent / ".cache",
help="Directory to cache downloaded data files")
args = parser.parse_args()
args.data_dir.mkdir(parents=True, exist_ok=True)
registry, relationships = download_data(args.data_dir)
print(f"Loaded {len(registry)} persons, {len(relationships)} relationships")
result = process_associates(registry, relationships)
print(f"Generated {len(result['names'])} name lookups for {len(result['persons'])} persons")
tier_counts = defaultdict(int)
for p in result["persons"].values():
tier_counts[p["tier"]] += 1
for tier in sorted(tier_counts):
print(f" Tier {tier}: {tier_counts[tier]} persons")
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(json.dumps(result, separators=(",", ":")))
print(f"Wrote {OUTPUT_PATH}")
if __name__ == "__main__":
main()