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]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-16 10:31:38 -06:00
1 parent d0e6a7c47a
commit 2268e8549e
5 files changed
+109 -6

No files matched your search

+51 -3
View File
@@ -62,6 +62,23 @@ NICKNAMES = {
# 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.
@@ -91,7 +108,7 @@ def _compute_tiers(registry: list[dict], relationships: list[dict]) -> dict[str,
if name in traveled:
tiers[name] = 1
elif category in TIER2_CATEGORIES or rel_counts.get(name, 0) >= 3:
elif category in TIER2_CATEGORIES or category in {"enabler", "perpetrator"} or rel_counts.get(name, 0) >= 3:
tiers[name] = 2
else:
tiers[name] = 3
@@ -154,13 +171,42 @@ def _generate_lookups_for_name(
_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: {...}}}
Returns: {"names": {str: [...]}, "persons": {str: {...}}, "victim_names": [str, ...]}
"""
tiers = _compute_tiers(registry, relationships)
@@ -193,7 +239,9 @@ def process_associates(
for alias in person.get("aliases", []):
_generate_lookups_for_name(names, alias, person_id, tier)
return {"names": names, "persons": persons}
victim_names = sorted(_build_victim_names())
return {"names": names, "persons": persons, "victim_names": victim_names}
def download_data(data_dir: Path) -> tuple[list, list]:
+20
View File
@@ -42,6 +42,8 @@ def test_process_returns_names_and_persons():
result = process_associates(_sample_registry(), _sample_relationships())
assert "names" in result
assert "persons" in result
assert "victim_names" in result
assert isinstance(result["victim_names"], list)
def test_tier1_traveled_with():
@@ -127,3 +129,21 @@ def test_nickname_generation():
names = result["names"]
assert "jeff" in names
assert any(m["match_type"] == "nickname" for m in names["jeff"])
def test_victim_names_generated():
"""Victim names should be a flat list of lowercased name variants."""
result = process_associates(_sample_registry(), _sample_relationships())
vn = result["victim_names"]
# Known victims from the KNOWN_VICTIMS set should produce entries
assert "virginia giuffre" in vn
assert "giuffre" in vn
assert "virginia" in vn
def test_victim_names_independent_of_associates():
"""Victim names don't require the person to be in the registry."""
result = process_associates([], [])
vn = result["victim_names"]
# Victims come from the hardcoded set, not the registry
assert "virginia giuffre" in vn
+1 -1
View File
@@ -1 +1 @@
{"names":{"*d - cbp offcr-c":[{"person_id":"p0000","match_type":"full","tier":3}],"*d":[{"person_id":"p0000","match_type":"first","tier":3},{"person_id":"p0001","match_type":"first","tier":3},{"person_id":"p0002","match_type":"first","tier":3}],"offcr-c":[{"person_id":"p0000","match_type":"last","tier":3},{"person_id":"p0003","match_type":"last","tier":3}],"*. offcr-c":[{"person_id":"p0000","match_type":"initial_last","tier":3},{"person_id":"p0003","match_type":"initial_last","tier":3}],"* offcr-c":[{"person_id":"p0000","match_type":"initial_last","tier":3},{"person_id":"p0003","match_type":"initial_last","tier":3}],"*d - cbp officer":[{"person_id":"p0001","match_type":"full","tier":3}],"officer":[{"person_id":"p0001","match_type":"last","tier":3},{"person_id":"p0004","match_type":"last","tier":3}],"*. officer":[{"person_id":"p0001","match_type":"initial_last","tier":3},{"person_id":"p0004","match_type":"initial_last","tier":3}],"* officer":[{"person_id":"p0001","match_type":"initial_last","tier":3},{"person_id":"p0004","match_type":"initial_last","tier":3}],"*d - cbp officer-c":[{"person_id":"p0002","match_type":"full","tier":3}],"officer-c":[{"person_id":"p0002","match_type":"last","tier":3},{"person_id":"p0005","match_type":"last","tier":3}],"*. officer-c":[{"person_id":"p0002","match_type":"initial_last","tier":3},{"person_id":"p0005","match_type":"initial_last","tier":3}],"* officer-c":[{"person_id":"p0002","match_type":"initial_last","tier":3},{"person_id":"p0005","match_type":"initial_last","tier":3}],"*f - cbp offcr-c":[{"person_id":"p0003","match_type":"full","tier":3}],"*f":[{"person_id":"p0003","match_type":"first","tier":3},{"person_id":"p0004","match_type":"first","tier":3},{"person_id":"p0005","match_type":"first","tier":3}],"*f - cbp officer":[{"person_id":"p0004","match_type":"full","tier":3}],"*f - cbp officer-c":[{"person_id":"p0005","match_type":"full","tier":3}],"36653-g-1159b-n908je-pbi-tist-foia-1336-no records ?":[{"person_id":"p0006","match_type":"full","tier":3}],"36653-g-1159b-n908je-pbi-tist-foia-1336-no":[{"person_id":"p0006","match_type":"first","tier":3}],"3. ?":[{"person_id":"p0006","match_type":"initial_last","tier":3},{"person_id":"p0007","match_type":"initial_last","tier":3},{"person_id":"p0008","match_type":"initial_last","tier":3},{"person_id":"p0009","match_type":"initial_last","tier":3},{"person_id":"p0010","match_type":"initial_last","tier":3},{"person_id":"p0011","match_type":"initial_last","tier":3},{"person_id":"p0012","match_type":"initial_last","tier":3},{"person_id":"p0013","match_type":"initial_last","tier":3},{"person_id":"p0014","match_type":"initial_last","tier":3},{"person_id":"p0015","match_type":"initial_last","tier":3},{"person_id":"p0016","match_type":"initial_last","tier":3},{"person_id":"p0017","match_type":"initial_last","tier":3},{"person_id":"p0018","match_type":"initial_last","tier":3},{"person_id":"p0019","match_type":"initial_last","tier":3},{"person_id":"p0020","match_type":"initial_last","tier":3},{"person_id":"p0021","match_type":"initial_last","tier":3},{"person_id":"p0022","match_type":"initial_last","tier":3},{"person_id":"p0023","match_type":"initial_last","tier":3},{"person_id":"p0024","match_type":"initial_last","tier":3},{"person_id":"p0025","match_type":"initial_last","tier":3}],"3 ?":[{"person_id":"p0006","match_type":"initial_last","tier":3},{"person_id":"p0007","match_type":"initial_last","tier":3},{"person_id":"p0008","match_type":"initial_last","tier":3},{"person_id":"p0009","match_type":"initial_last","tier":3},{"person_id":"p0010","match_type":"initial_last","tier":3},{"person_id":"p0011","match_type":"initial_last","tier":3},{"person_id":"p0012","match_type":"initial_last","tier":3},{"person_id":"p0013","match_type":"initial_last","tier":3},{"person_id":"p0014","match_type":"initial_last","tier":3},{"person_id":"p0015","match_type":"initial_last","tier":3},{"person_id":"p0016","match_type":"initial_last","tier":3},{"person_id":"p0017","match_type":"initial_last","tier":3},{"person_id":"p0018","match_type":"initial_last","tier":3},{"person_id":"p0019","match_type":"initial_last","tier":3},{"person_id":"p0020","match_type":"initial_last","tier":3},{"person_id":"p0021","match_type":"initial_last","tier":3},{"person_id":"p0022","match_type":"initial_last","tier":3},{"person_id":"p0023","match_type":"initial_last","tier":3},{"person_id":"p0024","match_type":"initial_last","tier":3},{"person_id":"p0025","match_type":"initial_last","tier":3}],"36738-g-1159b-n908je-saf-teb-foia-1371-no records ?":[{"person_id":"p0007","match_type":"full","tier":3}],"36738-g-1159b-n908je-saf-teb-foia-1371-no":[{"person_id":"p0007","match_type":"first","tier":3}],"36742-g-1159b-n908je-teb-pbi-foia-1372-no records ?":[{"person_id":"p0008","match_type":"full","tier":3}],"36742-g-1159b-n908je-teb-pbi-foia-1372-no":[{"person_id":"p0008","match_type":"first","tier":3}],"36746-g-1159b-n908je-pbi-tvc-foia-1373-no records ?":[{"person_id":"p0009","match_type":"Line truncated
{"names":{"*d - cbp offcr-c":[{"person_id":"p0000","match_type":"full","tier":3}],"*d":[{"person_id":"p0000","match_type":"first","tier":3},{"person_id":"p0001","match_type":"first","tier":3},{"person_id":"p0002","match_type":"first","tier":3}],"offcr-c":[{"person_id":"p0000","match_type":"last","tier":3},{"person_id":"p0003","match_type":"last","tier":3}],"*. offcr-c":[{"person_id":"p0000","match_type":"initial_last","tier":3},{"person_id":"p0003","match_type":"initial_last","tier":3}],"* offcr-c":[{"person_id":"p0000","match_type":"initial_last","tier":3},{"person_id":"p0003","match_type":"initial_last","tier":3}],"*d - cbp officer":[{"person_id":"p0001","match_type":"full","tier":3}],"officer":[{"person_id":"p0001","match_type":"last","tier":3},{"person_id":"p0004","match_type":"last","tier":3}],"*. officer":[{"person_id":"p0001","match_type":"initial_last","tier":3},{"person_id":"p0004","match_type":"initial_last","tier":3}],"* officer":[{"person_id":"p0001","match_type":"initial_last","tier":3},{"person_id":"p0004","match_type":"initial_last","tier":3}],"*d - cbp officer-c":[{"person_id":"p0002","match_type":"full","tier":3}],"officer-c":[{"person_id":"p0002","match_type":"last","tier":3},{"person_id":"p0005","match_type":"last","tier":3}],"*. officer-c":[{"person_id":"p0002","match_type":"initial_last","tier":3},{"person_id":"p0005","match_type":"initial_last","tier":3}],"* officer-c":[{"person_id":"p0002","match_type":"initial_last","tier":3},{"person_id":"p0005","match_type":"initial_last","tier":3}],"*f - cbp offcr-c":[{"person_id":"p0003","match_type":"full","tier":3}],"*f":[{"person_id":"p0003","match_type":"first","tier":3},{"person_id":"p0004","match_type":"first","tier":3},{"person_id":"p0005","match_type":"first","tier":3}],"*f - cbp officer":[{"person_id":"p0004","match_type":"full","tier":3}],"*f - cbp officer-c":[{"person_id":"p0005","match_type":"full","tier":3}],"36653-g-1159b-n908je-pbi-tist-foia-1336-no records ?":[{"person_id":"p0006","match_type":"full","tier":3}],"36653-g-1159b-n908je-pbi-tist-foia-1336-no":[{"person_id":"p0006","match_type":"first","tier":3}],"3. ?":[{"person_id":"p0006","match_type":"initial_last","tier":3},{"person_id":"p0007","match_type":"initial_last","tier":3},{"person_id":"p0008","match_type":"initial_last","tier":3},{"person_id":"p0009","match_type":"initial_last","tier":3},{"person_id":"p0010","match_type":"initial_last","tier":3},{"person_id":"p0011","match_type":"initial_last","tier":3},{"person_id":"p0012","match_type":"initial_last","tier":3},{"person_id":"p0013","match_type":"initial_last","tier":3},{"person_id":"p0014","match_type":"initial_last","tier":3},{"person_id":"p0015","match_type":"initial_last","tier":3},{"person_id":"p0016","match_type":"initial_last","tier":3},{"person_id":"p0017","match_type":"initial_last","tier":3},{"person_id":"p0018","match_type":"initial_last","tier":3},{"person_id":"p0019","match_type":"initial_last","tier":3},{"person_id":"p0020","match_type":"initial_last","tier":3},{"person_id":"p0021","match_type":"initial_last","tier":3},{"person_id":"p0022","match_type":"initial_last","tier":3},{"person_id":"p0023","match_type":"initial_last","tier":3},{"person_id":"p0024","match_type":"initial_last","tier":3},{"person_id":"p0025","match_type":"initial_last","tier":3}],"3 ?":[{"person_id":"p0006","match_type":"initial_last","tier":3},{"person_id":"p0007","match_type":"initial_last","tier":3},{"person_id":"p0008","match_type":"initial_last","tier":3},{"person_id":"p0009","match_type":"initial_last","tier":3},{"person_id":"p0010","match_type":"initial_last","tier":3},{"person_id":"p0011","match_type":"initial_last","tier":3},{"person_id":"p0012","match_type":"initial_last","tier":3},{"person_id":"p0013","match_type":"initial_last","tier":3},{"person_id":"p0014","match_type":"initial_last","tier":3},{"person_id":"p0015","match_type":"initial_last","tier":3},{"person_id":"p0016","match_type":"initial_last","tier":3},{"person_id":"p0017","match_type":"initial_last","tier":3},{"person_id":"p0018","match_type":"initial_last","tier":3},{"person_id":"p0019","match_type":"initial_last","tier":3},{"person_id":"p0020","match_type":"initial_last","tier":3},{"person_id":"p0021","match_type":"initial_last","tier":3},{"person_id":"p0022","match_type":"initial_last","tier":3},{"person_id":"p0023","match_type":"initial_last","tier":3},{"person_id":"p0024","match_type":"initial_last","tier":3},{"person_id":"p0025","match_type":"initial_last","tier":3}],"36738-g-1159b-n908je-saf-teb-foia-1371-no records ?":[{"person_id":"p0007","match_type":"full","tier":3}],"36738-g-1159b-n908je-saf-teb-foia-1371-no":[{"person_id":"p0007","match_type":"first","tier":3}],"36742-g-1159b-n908je-teb-pbi-foia-1372-no records ?":[{"person_id":"p0008","match_type":"full","tier":3}],"36742-g-1159b-n908je-teb-pbi-foia-1372-no":[{"person_id":"p0008","match_type":"first","tier":3}],"36746-g-1159b-n908je-pbi-tvc-foia-1373-no records ?":[{"person_id":"p0009","match_type":"Line truncated
+30 -2
View File
@@ -102,10 +102,12 @@ async function loadAssociates() {
try {
const resp = await fetch("/api/associates");
state.associates = await resp.json();
console.log(`Loaded ${Object.keys(state.associates.names).length} associate lookups`);
// Convert victim_names array to Set for fast lookup
state.associates.victim_set = new Set(state.associates.victim_names || []);
console.log(`Loaded ${Object.keys(state.associates.names).length} associate lookups, ${state.associates.victim_set.size} victim names`);
} catch (e) {
console.warn("Failed to load associates data:", e);
state.associates = { names: {}, persons: {} };
state.associates = { names: {}, persons: {}, victim_set: new Set() };
}
}
@@ -930,6 +932,22 @@ function tierDescription(tier) {
return "Named in Epstein case files";
}
function isVictimMatch(text) {
const vs = state.associates?.victim_set;
if (!vs || vs.size === 0) return false;
const key = text.toLowerCase().trim();
if (vs.has(key)) return true;
// Also check with prefix/suffix combined
const prefix = solveFilterPrefix.value.toLowerCase().trim();
const suffix = solveFilterSuffix.value.toLowerCase().trim();
if (prefix || suffix) {
if (vs.has(prefix + key + suffix)) return true;
if (prefix && vs.has(prefix + key)) return true;
if (suffix && vs.has(key + suffix)) return true;
}
return false;
}
function showAssocDetail(assocMatches, anchorEl) {
// Remove any existing popup
const old = document.getElementById("assoc-detail");
@@ -1090,6 +1108,16 @@ function handleSolveEvent(data, gapIdx) {
<span class="result-error">${data.error_px.toFixed(1)}px ${data.source || ""}</span>
`;
const victim = isVictimMatch(data.text);
if (victim) {
const vBadge = document.createElement("span");
vBadge.className = "assoc-badge victim";
vBadge.textContent = "V";
vBadge.title = "Matches a known victim name";
div.prepend(vBadge);
}
if (assocMatches.length > 0) {
const badge = document.createElement("button");
badge.className = `assoc-badge ${tierBadgeClass(topMatch.tier)}`;
+7
View File
@@ -521,6 +521,13 @@ main {
color: #000;
}
.assoc-badge.victim {
background: #7b1fa2;
color: #fff;
cursor: default;
pointer-events: none;
}
/* Associate detail popup */
#assoc-detail {
position: absolute;