goon/app/scheduler/junk_performers.py
goon-foss 9af5fd4eb4 fix(performers): zbedne przypisania jednotokenowe obok osoby pelnoimiennej
Ranking z poprzedniego commita ujawnil klase, ktorej zaden wczesniejszy sygnal nie
widzial: wpisy bedace SAMYM NAZWISKIEM albo samym imieniem, powstale z rozbicia peleno
nazwy przez tube-search. "Devine" obok "Shalina Devine", "Kitana" obok "Kitana A",
"Rayne" obok "Tiffany Rayne". Taki wpis lapie potem kazda scene z tym slowem,
nalezaca do zupelnie innej osoby.

Regula jest DOWODLIWIE BEZSTRATNA: kasujemy przypisanie jednotokenowe tylko wtedy, gdy
ta sama scena ma juz przypisana osobe pelnoimienna zawierajaca to slowo. Informacja o
obsadzie zostaje, znika tylko niejednoznaczny duplikat. Kontrola potwierdzila, ze zadna
scena nie zostala przez to bez obsady (0).

Prowenancja z migracji 0026 dala zabezpieczenie: z 62 783 zbednych przypisan 62 120 to
scraper (skasowane), 73 kanon (chronione), 590 nieznane (zostawione). Bez niej nie dalo
by sie tego rozdzielic.

Wynik: 62 120 przypisan zdjetych, 2156 wpisow odchudzonych (Devine 170 na 60), 1740
pustych rekordow usunietych. Regula dopieta do joba junk_performers, drugi przebieg
daje zera.

Zostaje residuum: sceny, na ktorych osoba pelnoimienna NIE byla przypisana, wiec
regula nie miala sie o co oprzec (James, Blue, Moon, Crystal, Cruz, Starr, Knight po
60-160 przypisan). Tam trzeba by DOPISAC wlasciwa osobe, a nie kasowac - to osobne,
bardziej ryzykowne zadanie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 09:23:14 +02:00

232 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Kasowanie śmieciowych performerów — kategorii i studiów podszywających się pod osoby.
Skąd się biorą: `_search_base` filtruje wyniki tube-searcha warunkiem „slug zawiera
≥1 token z zapytania", a `resolve_performer` tworzy performera z dowolnej nazwy podanej
przez tube. W efekcie „Creampie", „Natural tits", „reverse cowgirl", „69", „Brazzers"
i „Blacked" wylądowały w tabeli `performers`. Audyt 2026-07-27: 277 takich rekordów z
33 784 przypisaniami do scen.
**Kryterium jest wąskie celowo, bo szerokie kasuje ludzi.** Kryterium używane w
`app/api/scenes.py` do de-rankingu (slug pokrywa się z tagiem I brak refa tpdb/stashdb)
daje 3111 trafień, ale przy próbkowaniu okazało się mieć ~60% fałszywek dla nazw
wielowyrazowych: wpadają w nie **Hatano Yui, Davina Raines, Michelle Ferrari, Clanddi,
SolaZola, Kate Kuray**. Powód: założenie „prawdziwy performer ma ref kanoniczny" nie
działa dla JAV, amatorek i debiutantek, których nie ma w TPDB. Do de-rankingu to
wystarcza (fałszywka = gorszy ranking), do kasowania nie.
Rozstrzyga dopiero **promiskuityczność taga**: ile RÓŻNYCH innych performerów pojawia
się na scenach otagowanych tym samym slugiem. Nazwisko współwystępuje głównie ze swoim
właścicielem i partnerami ze sceny, kategoria z całym katalogiem. Zmierzone:
prawdziwe osoby Hatano Yui 0, Clanddi 0, Chyna 0, Kwini 2, Katekuray 9,
SolaZola 4, Julia Reaves 20 (maksimum 20)
kategorie Deepthroat 39541, Creampie 33661, riding 16284, Skinny 9443
Próg 50 leży ponad dwukrotnie powyżej najwyższej prawdziwej osoby. Dodatkowo chronimy
nazwy w formacie „Imię Nazwisko", bo sygnał studia (odrzucony) wciągał marki osobiste.
**Drugie kryterium: tag przytłacza performera.** Promiskuityczność zawodzi dla śmieci,
które mają ref kanoniczny — bo TPDB i StashDB SAME zawierają wpisy „Hardcore", „Blonde",
„Masturbation", „Amateur". Co gorsza, `obcy` mierzy tam popularność, nie śmieciowość:
przy wartości 50-59 stoją Adriana Chechik (4201 scen), Lauren Phillips i Jane Wilde.
Rozstrzyga stosunek `scen z tagiem / scen performera`. Nazwisko-tag siedzi głównie na
scenach swojej właścicielki, więc stosunek jest ułamkiem; kategoria wisi na całym
katalogu, więc idzie w setki:
Hardcore 1720,6 Masturbation 1570,4 Blonde 770,0 Anal Creampie 31,4
najwyższy wśród 921 PEWNYCH osób (ref kanoniczny + ≥500 scen): **0,35**
**Próg 2 wynika z zasady, nie z próbki.** Nazwisko użyte jako własny tag daje stosunek
≈1 Z DEFINICJI, bo tag siedzi dokładnie na scenach swojej właścicielki — potwierdzone na
twórcach z własną marką: Hotwifevictoria 1,02, Rossmexicana 0,99, Sexwithmilfstella 0,87.
Wielkie gwiazdy mają jeszcze mniej (tuby tagują tylko część ich scen). Stosunek ≥2
oznacza więc, że słowo jest doklejane do scen, które do tej osoby nie należą.
Wymóg ≥50 scen odcina szum przy małych liczbach (przy 11 scenach „Denzel" wychodził na
161, ale to za mało danych, żeby przesądzać). Przy progu 2 na 78 trafień dwa są
dyskusyjne („Teddy", „Grey" mogą być nazwiskami) — ale i one mają tag rozlany na 3×
więcej scen, niż mają przypisań, więc ich atrybucje i tak są w większości słowne.
Job, nie bramka w `resolve_performer`: kasowanie cyklem nie dotyka gorącej ścieżki
ingestu, a śmieci i tak odrastają wolno. Dotyczy to zwłaszcza tego drugiego kryterium,
bo tamte rekordy odtwarza sam ingest TPDB/StashDB. Ta sama logika co przy
[[phash_blacklist]].
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
#: Ponad 2× powyżej najwyższej zmierzonej prawdziwej osoby (Julia Reaves, 20).
DEFAULT_MIN_FOREIGN_PERFORMERS = 50
#: 2× powyżej naturalnej wartości dla nazwiska użytego jako własny tag (≈1).
DEFAULT_MIN_TAG_RATIO = 2
#: Poniżej tego stosunek jest szumem — za mało scen, żeby cokolwiek przesądzać.
DEFAULT_MIN_SCENES_FOR_RATIO = 50
#: Nazwy 1-2 znakowe („A", „L", „Ro", „Js") to artefakty parsowania tytułów, ale
#: kilka to realne krótkie pseudonimy (Cj, PD, JD mają obsadę potwierdzoną w TPDB
#: i StashDB, z prawdziwymi partnerami scenicznymi). Dlatego NIE kasujemy tu
#: performera, tylko przypisania, o których WIEMY, że dokleił je scraper — reszta
#: rozstrzyga się sama: komu nic nie zostanie, ten znika.
MAX_ARTIFACT_NAME_LEN = 2
_SELECT_SQL = """
WITH junk AS (
SELECT pf.id, pf.canonical_name, pf.slug
FROM performers pf
WHERE EXISTS (SELECT 1 FROM tags t WHERE t.slug = pf.slug)
AND NOT EXISTS (
SELECT 1 FROM performer_external_refs r JOIN sources s ON s.id = r.source_id
WHERE r.performer_id = pf.id AND s.kind IN ('tpdb', 'stashdb')
)
-- „Imię Nazwisko" zostaje: marki osobiste (Kate Kuray, Julia Reaves) wyglądają
-- jak studio, ale są osobami.
AND pf.canonical_name !~ '^[A-Z][a-z]+ [A-Z][a-z]+$'
)
SELECT j.id FROM junk j
WHERE (
SELECT count(DISTINCT sp.performer_id)
FROM tags t JOIN scene_tags st ON st.tag_id = t.id
JOIN scene_performers sp ON sp.scene_id = st.scene_id
WHERE t.slug = j.slug AND sp.performer_id <> j.id
) >= :min_foreign
"""
_RATIO_SQL = """
SELECT pf.id
FROM performers pf
WHERE EXISTS (SELECT 1 FROM tags t WHERE t.slug = pf.slug)
AND (SELECT count(*) FROM scene_performers sp WHERE sp.performer_id = pf.id) >= :min_scenes
AND (SELECT count(*) FROM scene_tags st JOIN tags t ON t.id = st.tag_id WHERE t.slug = pf.slug)::numeric
/ NULLIF((SELECT count(*) FROM scene_performers sp WHERE sp.performer_id = pf.id), 0) >= :min_ratio
"""
def find_junk_performers(
session: Session,
*,
min_foreign: int = DEFAULT_MIN_FOREIGN_PERFORMERS,
min_ratio: int = DEFAULT_MIN_TAG_RATIO,
min_scenes: int = DEFAULT_MIN_SCENES_FOR_RATIO,
):
"""Suma dwóch niezależnych kryteriów — pierwsze łapie śmieci bez refa kanonicznego,
drugie te, które ref mają (bo TPDB/StashDB też zawierają „Hardcore" czy „Blonde")."""
ids = {r[0] for r in session.execute(text(_SELECT_SQL), {"min_foreign": min_foreign})}
ids |= {
r[0]
for r in session.execute(
text(_RATIO_SQL), {"min_ratio": min_ratio, "min_scenes": min_scenes}
)
}
return list(ids)
# Jednotokenowy performer jest ZBĘDNY na scenie, która ma już osobę pełnoimienną
# zawierającą to samo słowo: „Devine" obok „Shalina Devine", „Kitana" obok „Kitana A".
# To rozbite zapisy tej samej osoby, powstałe z parsowania tytułów przez tube-search —
# jeden wpis nazwiskiem łapie potem wszystkie sceny z tym nazwiskiem, należące do
# zupełnie innych ludzi (audyt 2026-07-27: 62 tys. takich przypisań, 2156 wpisów).
#
# Kasowanie jest DOWODLIWIE BEZSTRATNE: warunek wymaga, żeby konkretna osoba już była
# przypisana do tej sceny, więc informacja o obsadzie zostaje. Żadna scena nie może
# przez to zostać bez obsady (sprawdzone: 0).
#
# Ograniczone do `kind='scraper'` — przypisania z kanonu zostają nietknięte (73 takie).
_PRUNE_SURNAME_SQL = """
DELETE FROM scene_performers sp
USING sources s, performers p
WHERE p.id = sp.performer_id
AND p.canonical_name !~ ' '
AND length(p.canonical_name) >= 4
AND s.id = sp.source_id
AND s.kind = 'scraper'
AND EXISTS (
SELECT 1 FROM scene_performers sp2 JOIN performers q ON q.id = sp2.performer_id
WHERE sp2.scene_id = sp.scene_id AND q.id <> p.id AND q.canonical_name ~ ' '
AND q.canonical_name ~* ('\\m' || p.canonical_name || '\\M')
)
"""
_PRUNE_ARTIFACT_SQL = """
DELETE FROM scene_performers sp
USING sources s, performers p
WHERE p.id = sp.performer_id
AND length(trim(p.canonical_name)) <= :max_len
AND s.id = sp.source_id
AND s.kind = 'scraper'
"""
_DROP_EMPTY_ARTIFACT_SQL = """
DELETE FROM performers p
WHERE length(trim(p.canonical_name)) <= :max_len
AND NOT EXISTS (SELECT 1 FROM scene_performers sp WHERE sp.performer_id = p.id)
"""
def prune_artifact_name_attributions(
session: Session, *, max_len: int = MAX_ARTIFACT_NAME_LEN
) -> tuple[int, int]:
"""Zdejmij scraperowe przypisania w dwóch przypadkach i usuń tych, którym nic nie
zostało: (1) nazwy 1-2 znakowe, (2) jednotokenowy performer na scenie, która ma już
osobę pełnoimienną zawierającą to słowo. Kanoniczne przypisania zostają nietknięte.
Wymaga wypełnionego `scene_performers.source_id` (migracja 0026): bez provenance nie
dałoby się odróżnić „Cj" z obsady TPDB od „Cj" wyłuskanego z tytułu przez tube-search.
Wiersze z NULL-em (sceny wieloźródłowe) zostawiamy — nie zgadujemy."""
pruned = int(
session.execute(text(_PRUNE_ARTIFACT_SQL), {"max_len": max_len}).rowcount or 0
)
pruned += int(session.execute(text(_PRUNE_SURNAME_SQL)).rowcount or 0)
dropped = int(
session.execute(text(_DROP_EMPTY_ARTIFACT_SQL), {"max_len": max_len}).rowcount or 0
)
dropped += int(
session.execute(
text(
"DELETE FROM performers p WHERE p.canonical_name !~ ' ' "
"AND length(p.canonical_name) >= 4 "
"AND NOT EXISTS (SELECT 1 FROM scene_performers sp "
"WHERE sp.performer_id = p.id) "
"AND NOT EXISTS (SELECT 1 FROM performer_external_refs r "
"JOIN sources s ON s.id = r.source_id WHERE r.performer_id = p.id "
"AND s.kind IN ('tpdb','stashdb'))"
)
).rowcount or 0
)
return pruned, dropped
def run_junk_performer_cleanup(
*, min_foreign: int = DEFAULT_MIN_FOREIGN_PERFORMERS, commit: bool = True
) -> dict[str, int]:
"""Skasuj śmieciowych performerów. `scene_performers` leci kaskadą — sceny i tagi
zostają, więc treść nie ginie: „creampie" nadal jest tagiem przy 176 tys. scen,
tylko przestaje udawać osobę."""
from app.db import session_scope
with session_scope() as session:
ids = find_junk_performers(session, min_foreign=min_foreign)
deleted = pruned = dropped = 0
if commit:
if ids:
deleted = int(
session.execute(
text("DELETE FROM performers WHERE id = ANY(:ids)"), {"ids": ids}
).rowcount or 0
)
pruned, dropped = prune_artifact_name_attributions(session)
session.commit()
log.info(
"junk performers: znaleziono %d, skasowano %d; nazwy 1-2 znakowe: zdjęto %d "
"przypisań, usunięto %d pustych",
len(ids), deleted, pruned, dropped,
)
return {"found": len(ids), "deleted": deleted, "pruned": pruned, "dropped": dropped}