goon/scripts/fix_mononym_attributions.py
goon-foss 0d4e11bc31 fix(performers): odzyskaj sceny przypisane do samego czlonu nazwiska
Siedem performerow o nazwie JEDNOCZLONOWEJ ("James", "Blue", "Moon",
"Crystal", "Cruz", "Starr", "Knight") zbieralo sceny wielu roznych osob
dzielacych ten czlon: "Blue" mial sceny Anity, Delilah i Bonnie Blue,
"Starr" mial Violet, Natashe i Bobbi Starr.

prune_token_overlap_attributions z zalozenia ich NIE RUSZAL i to nie byl
niedopatrzenie: dla nazwy jednoczlonowej za dowod uznaje samo wystapienie
tego slowa w tytule, wiec "James" jest "udokumentowany" przez kazda scene
wspominajaca jakiegokolwiek Jamesa. Stad 0 skasowanych z tej siodemki.

To NIE bylo kasowanie, tylko ODZYSKIWANIE: w probce 14/14 wlasciwa osoba
NIE byla przypisana do sceny, czyli Bonnie Blue nie miala tych scen na
swojej stronie.

Kryterium: przypisanie ze scrapera (kanon nietykalny — z 605 przypisan
tych siedmiu 518 jest ze scrapera) + istnieje performer, ktorego nazwa
konczy sie tym czlonem po spacji + ta PELNA nazwa stoi w tytule sceny
jako ciag calych slow. Ostatni warunek odroznia odzyskanie od zgadywania.

Wynik: dolozono 271 wlasciwych przypisan, zdjeto 268 mononimow (roznica
to sceny, gdzie w tytule stoja DWIE takie osoby, np. Bonnie i Mick Blue —
obie sa dokladane, mononim schodzi raz).

Zyskali m.in.: Violet Starr +22 sceny, Mick Blue +16, Stacy Cruz +15,
Rachel Starr +13, Alexis Crystal +12, Natasha Starr +8.

Mononimy spadly: Starr 93->28, Cruz 45->8, Moon 64->23, Blue 113->73.
Reszta zostaje nietknieta, bo nie ma w tytule wskazanego wlasciciela —
"Blue" i "Cruz" maja refy StashDB, wiec czesc z nich moze byc prawdziwa.

Zdjete wiersze ida do scene_performers_pruned, cofniecie to INSERT..SELECT.
2026-08-14 10:34:14 +02:00

132 lines
5.5 KiB
Python

"""Przypisuje WŁAŚCIWĄ osobę tam, gdzie scena dostała sam człon jej nazwiska.
W bazie siedzi siedem performerów o nazwie jednoczłonowej („James", „Blue", „Moon",
„Crystal", „Cruz", „Starr", „Knight"), którzy zbierają sceny wielu różnych osób
dzielących ten człon: „Blue" ma sceny Anity Blue, Delilah Blue i Bonnie Blue, „Starr"
ma Violet, Natashę i Bobbi Starr.
**To NIE jest ten sam przypadek co `prune_token_overlap_attributions`** i tamten skrypt
z założenia ich nie rusza: dla nazwy jednoczłonowej za dowód uznaje samo wystąpienie
tego słowa w tytule, więc „James" jest „udokumentowany" przez każdą scenę wspominającą
jakiegokolwiek Jamesa. Stąd 0 skasowanych z tych siedmiu przy sprzątaniu 2026-08-13.
**I nie jest to kasowanie, tylko ODZYSKIWANIE.** W próbce 14/14 właściwa osoba NIE była
przypisana do sceny, czyli Bonnie Blue nie miała tych scen na swojej stronie. Skrypt
dokłada właściwego performera i zdejmuje mononim.
Kryterium (wszystkie warunki naraz):
1. przypisanie mononimu pochodzi ze scrapera (kanon nietykalny; z 605 przypisań tych
siedmiu 518 jest ze scrapera, reszta z TPDB/StashDB i tej nie dotykamy),
2. w bazie istnieje performer, którego nazwa KOŃCZY SIĘ tym członem po spacji
(„% blue" łapie „Bonnie Blue", nie łapie „Blue"),
3. ta pełna nazwa występuje w tytule sceny jako ciąg całych słów.
Warunek 3 jest tym, co odróżnia odzyskanie od zgadywania: nie wystarczy, że ktoś taki
istnieje, jego nazwisko musi stać w tytule tej konkretnej sceny.
Gdy w tytule stoją DWIE takie osoby („Bonnie Blue and Mick Blue"), dokładamy obie
i zdejmujemy mononim raz. To poprawne, bo scena faktycznie ma ich obu.
Uruchomienie (kontener worker):
python -m scripts.fix_mononym_attributions # dry-run
python -m scripts.fix_mononym_attributions --yes # wykonaj
"""
from __future__ import annotations
import argparse
import logging
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
#: Nazwy jednoczłonowe zbierające cudze sceny. Lista jawna, NIE heurystyka: mononimy
#: bywają prawdziwe (StashDB ma performerki „Blue" i „Cruz"), więc nie chcemy tu trafić
#: przypadkiem w kogoś, kto naprawdę występuje pod jednym imieniem.
MONONYMS = ("James", "Blue", "Moon", "Crystal", "Cruz", "Starr", "Knight")
_BUILD = """
CREATE TEMP TABLE kand ON COMMIT DROP AS
SELECT sp.scene_id,
sp.performer_id AS mono_id,
sp.source_id,
p2.id AS wlasciwy_id
FROM scene_performers sp
JOIN sources src ON src.id = sp.source_id AND src.name = 'tube-scraper'
JOIN performers m ON m.id = sp.performer_id AND m.canonical_name = ANY(:names)
JOIN scenes s ON s.id = sp.scene_id
JOIN performers p2
ON lower(p2.canonical_name) LIKE '% ' || lower(m.canonical_name)
AND p2.id <> m.id
-- pełna nazwa musi stać w tytule jako ciąg CAŁYCH słów; `strpos` po znormalizowanym,
-- obustronnie odspacjowanym tekście, żeby „Mia Blue" nie łapało się w „Miami Blues"
AND strpos(' ' || regexp_replace(lower(s.title), '[^a-z0-9]+', ' ', 'g') || ' ',
' ' || regexp_replace(lower(p2.canonical_name), '[^a-z0-9]+', ' ', 'g') || ' ') > 0
"""
#: Dokładamy właściwą osobę. `ON CONFLICT DO NOTHING`, bo bywa już przypisana.
#: `source_id` przepisujemy z wiersza mononimu: przypisanie pochodzi z tego samego
#: scrapera, tylko trafiło w zły rekord.
_ATTACH = """
INSERT INTO scene_performers (scene_id, performer_id, source_id)
SELECT DISTINCT scene_id, wlasciwy_id, source_id FROM kand
ON CONFLICT DO NOTHING
"""
_ARCHIVE = """
INSERT INTO scene_performers_pruned
(scene_id, performer_id, source_id, performer_name, scene_title)
SELECT DISTINCT sp.scene_id, sp.performer_id, sp.source_id, p.canonical_name, s.title
FROM kand k
JOIN scene_performers sp ON sp.scene_id = k.scene_id AND sp.performer_id = k.mono_id
JOIN performers p ON p.id = sp.performer_id
JOIN scenes s ON s.id = sp.scene_id
ON CONFLICT DO NOTHING
"""
_DETACH = """
DELETE FROM scene_performers sp
USING (SELECT DISTINCT scene_id, mono_id FROM kand) d
WHERE sp.scene_id = d.scene_id AND sp.performer_id = d.mono_id
"""
_REPORT = """
SELECT m.canonical_name AS mononim,
count(*) AS przypisan,
count(DISTINCT k.wlasciwy_id) AS roznych_osob
FROM kand k JOIN performers m ON m.id = k.mono_id
GROUP BY m.canonical_name ORDER BY 2 DESC
"""
def run(*, apply: bool) -> dict[str, int]:
with session_scope() as session:
session.execute(text(_BUILD), {"names": list(MONONYMS)})
rows = session.execute(text(_REPORT)).all()
for name, n, people in rows:
print(f" {name:9} {n:>4} przypisań → {people} różnych osób", flush=True)
total = sum(r[1] for r in rows)
if not apply:
print(f"\nRAZEM: {total} do naprawy (dry-run)")
return {"matched": total, "attached": 0, "detached": 0}
attached = session.execute(text(_ATTACH)).rowcount or 0
session.execute(text(_ARCHIVE))
detached = session.execute(text(_DETACH)).rowcount or 0
print(f"\nRAZEM: dołożono {attached} właściwych przypisań, zdjęto {detached} mononimów")
return {"matched": total, "attached": attached, "detached": detached}
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
ap = argparse.ArgumentParser()
ap.add_argument("--yes", action="store_true", help="wykonaj (bez tego dry-run)")
run(apply=ap.parse_args().yes)
if __name__ == "__main__":
main()