goon/scripts/prune_token_overlap_attributions.py
goon-foss 91c2ce6e0a feat(cleanup): archiwum przed kasowaniem bledych przypisan performerow
Kasowane wiersze ida najpierw do `scene_performers_pruned`, w TEJ SAMEJ
transakcji co DELETE: albo wiersz jest zapisany i skasowany, albo nie
dzieje sie nic. Cofniecie to `INSERT ... SELECT`, nie backup calej bazy.
Archiwum trzyma tez nazwe performera i tytul sceny, wiec da sie je
przejrzec bez odtwarzania joinow. Tabela swiadomie poza alembikiem: to
slad po jednorazowym sprzataniu, nie czesc schematu.

Kryterium doprecyzowane po dwoch losowych probkach:

1. „brak nazwy w tytule" samo w sobie kasowalo MESKICH performerow (tuby
   nie wypisuja ich w tytulach, przypisanie szlo z listy obsady) -> stad
   warunek „inny performer sceny ma dowod I dzieli czlon".
2. Dowodem dla nazwy o >=2 czlonach jest kazdy czlon jako OSOBNE SLOWO,
   nie spojna fraza: „Silvia and Eveline Dellai" to scena Silvii Dellai,
   ale ciagu „silviadellai" tam nie ma (1 blad na 22 w probce). Nazwy o
   jednym uzytecznym czlonie („Eliza E") zostaja przy ostrym kryterium,
   bo inaczej lapia kazda inna Elize.
3. Granica slowa przez `strpos` na znormalizowanym tekscie, NIE regex:
   nazwy z nawiasami wywalaly caly przebieg („parentheses not balanced"),
   a metaznaki po cichu psulyby dopasowania.

Dry-run: 321 833 przypisania (vs 370 587 przed poprawka 2). Szkoda
rozproszona, nie skoncentrowana: rekordzistka traci 20 z 909.
2026-08-13 12:16:13 +02:00

208 lines
8.8 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.

"""Kasuje przypisania performerów powstałe przez CZĘŚCIOWE dopasowanie nazwy.
Mechanizm, który to produkował (naprawiony 2026-08-13 w `_search_base._slug_matches_query`):
search po nazwie doklejał szukanego performera do KAŻDEJ sceny, która przeszła filtr
tokenów, a filtr testował PODCIĄG na luźnych tokenach. Więc „Tiffany Time" łapało się
w „s-tiffany-love-first-time", a „Eliza E" w „eliza-ibarra". Zgłoszenie 2026-08-13:
strona Tiffani Time pełna scen o Tiffany Tatum, Tiffani Madison i Victorii Tiffani.
**Kryterium (trzy warunki naraz, każdy konieczny):**
1. przypisanie pochodzi ze scrapera (kanon nietykalny, `scene_performers.source_id`),
2. nazwy performera NIE ma w tytule ani URL-u sceny (brak dowodu),
3. na tej samej scenie jest INNY performer, który nazwę ma **i dzieli z nim człon**.
Trzeci warunek jest tym, co odróżnia pomyłkę od braku danych, i został dołożony po
obejrzeniu próbki. Bez niego reguła kasowała **męskich performerów**: tuby zwyczajowo
nie wypisują ich w tytule („Charles Dera" przy scenie „HouseHumpers – Demi Hawks and
Serena Hill"), a przypisanie przyszło z listy obsady, nie z searcha, więc było poprawne.
Po zawężeniu próbka 14/14 to realne pomyłki: Nikki Nevada przy Nikki Brooks, Eliza E
przy Elizie Ibarze, Miss Marisa przy Miss Fufu, CJ Sloan przy Sloan Rider.
Wsadowo po zakresach `scene_id`, bo pojedynczy DELETE po całej tabeli trzymałby locki
na `scene_performers` i zablokował ingest (zdarzyło się przy migracji 0026).
Kasowane wiersze lądują najpierw w `scene_performers_pruned` (w tej samej transakcji),
więc cofnięcie to `INSERT INTO scene_performers SELECT scene_id, performer_id, source_id
FROM scene_performers_pruned`, a nie odtwarzanie bazy z backupu.
Uruchomienie (kontener worker):
python -m scripts.prune_token_overlap_attributions # dry-run
python -m scripts.prune_token_overlap_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__)
#: Archiwum kasowanych wierszy. Świadomie POZA alembikiem: to tabela operacyjna,
#: jednorazowy ślad po sprzątaniu, nie część schematu, którego pilnują migracje.
#: Trzymamy w niej też nazwę performera i tytuł sceny, żeby dało się to przejrzeć
#: bez odtwarzania joinów, a cofnięcie było jednym `INSERT ... SELECT`.
_ARCHIVE_DDL = """
CREATE TABLE IF NOT EXISTS scene_performers_pruned (
scene_id uuid NOT NULL,
performer_id uuid NOT NULL,
source_id uuid,
performer_name text,
scene_title text,
pruned_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (scene_id, performer_id)
)
"""
#: Kandydaci do archiwum i kasowania — jedna definicja, używana przez oba kroki,
#: żeby archiwum nie mogło się rozjechać z tym, co faktycznie znika.
_CANDIDATES_SQL = """
SELECT e.scene_id, e.performer_id
FROM ev e
WHERE NOT e.ma_dowod
AND EXISTS (
SELECT 1 FROM ev e2
WHERE e2.scene_id = e.scene_id
AND e2.performer_id <> e.performer_id
AND e2.ma_dowod
AND e2.tokeny && e.tokeny
)
"""
_ARCHIVE_SQL = f"""
INSERT INTO scene_performers_pruned
(scene_id, performer_id, source_id, performer_name, scene_title)
SELECT sp.scene_id, sp.performer_id, sp.source_id, p.canonical_name, s.title
FROM ({_CANDIDATES_SQL}) d
JOIN scene_performers sp ON sp.scene_id = d.scene_id AND sp.performer_id = d.performer_id
JOIN performers p ON p.id = sp.performer_id
JOIN scenes s ON s.id = sp.scene_id
ON CONFLICT DO NOTHING
"""
_DELETE_SQL = f"""
DELETE FROM scene_performers sp
USING ({_CANDIDATES_SQL}) d
WHERE sp.scene_id = d.scene_id AND sp.performer_id = d.performer_id
"""
#: 16 przedziałów po pierwszym nibblu UUID-a. Każdy to osobna transakcja.
_PREFIXES = "0123456789abcdef"
_BUILD_SQL = """
CREATE TEMP TABLE ev ON COMMIT DROP AS
SELECT sp.scene_id,
sp.performer_id,
CASE
-- Nazwa o >=2 uzytecznych czlonach: dowodem jest obecnosc KAZDEGO z nich jako
-- OSOBNEGO SLOWA, niekoniecznie obok siebie. Inaczej wypadaly osoby wymieniane
-- lacznie: „Silvia and Eveline Dellai" to scena Silvii Dellai, ale ciag
-- „silviadellai" w niej nie wystepuje (probka losowa: 1 blad na 22).
--
-- Granica slowa realizowana przez `strpos` na znormalizowanym, obustronnie
-- odspacjowanym tekscie, a NIE przez regex: nazwy potrafia zawierac nawiasy
-- („… (Brazil)") i wstawione do wyrazenia wywalaly caly przebieg
-- („parentheses not balanced"). Przy okazji `strpos` nie ma metaznakow,
-- wiec `%` i `_` w nazwie tez sa bezpieczne.
WHEN cardinality(ARRAY(SELECT t FROM unnest(string_to_array(lower(p.canonical_name),' ')) t
WHERE length(t) >= 3)) >= 2
THEN NOT EXISTS (
SELECT 1
FROM unnest(string_to_array(lower(p.canonical_name),' ')) t
WHERE length(regexp_replace(t, '[^a-z0-9]+', '', 'g')) >= 3
AND strpos(
' ' || regexp_replace(
lower(coalesce(ps.page_url,'') || ' ' || s.title),
'[^a-z0-9]+', ' ', 'g') || ' ',
' ' || regexp_replace(t, '[^a-z0-9]+', '', 'g') || ' '
) = 0
)
-- Nazwa sprowadzajaca sie do JEDNEGO czlonu („Eliza E", „Pinky 5") degeneruje sie
-- do „scena zawiera to imie", czyli lapie kazda inna Elize. Tu wymagamy calej
-- nazwy sklejonej, tak jak robi to bramka searcha.
ELSE replace(replace(replace(
lower(coalesce(ps.page_url,'') || ' ' || s.title), '-',''), '_',''), ' ','')
LIKE '%' || replace(lower(p.canonical_name),' ','') || '%'
END AS ma_dowod,
ARRAY(
SELECT t FROM unnest(string_to_array(lower(p.canonical_name),' ')) t
WHERE length(t) >= 3
) AS tokeny
FROM scene_performers sp
JOIN sources src ON src.id = sp.source_id AND src.name = 'tube-scraper'
JOIN performers p ON p.id = sp.performer_id
JOIN scenes s ON s.id = sp.scene_id
LEFT JOIN LATERAL (
SELECT page_url FROM playback_sources x WHERE x.scene_id = s.id LIMIT 1
) ps ON true
WHERE sp.scene_id >= CAST(:lo AS uuid) AND sp.scene_id < CAST(:hi AS uuid);
CREATE INDEX ON ev (scene_id);
ANALYZE ev;
"""
#: Kandydaci: bez dowodu, ale inny performer TEJ SCENY dowód ma i dzieli człon.
_SELECT_SQL = """
SELECT e.scene_id, e.performer_id
FROM ev e
WHERE NOT e.ma_dowod
AND EXISTS (
SELECT 1 FROM ev e2
WHERE e2.scene_id = e.scene_id
AND e2.performer_id <> e.performer_id
AND e2.ma_dowod
AND e2.tokeny && e.tokeny
)
"""
def _range(prefix: str) -> tuple[str, str]:
lo = prefix + "0000000-0000-0000-0000-000000000000"
nxt = _PREFIXES[_PREFIXES.index(prefix) + 1] if prefix != "f" else None
hi = (nxt + "0000000-0000-0000-0000-000000000000") if nxt else "ffffffff-ffff-ffff-ffff-ffffffffffff"
return lo, hi
def run(*, apply: bool) -> dict[str, int]:
total = 0
if apply:
with session_scope() as session:
session.execute(text(_ARCHIVE_DDL))
for prefix in _PREFIXES:
lo, hi = _range(prefix)
with session_scope() as session:
for stmt in _BUILD_SQL.strip().split(";\n"):
if stmt.strip():
session.execute(text(stmt), {"lo": lo, "hi": hi})
rows = session.execute(text(_SELECT_SQL)).all()
n = len(rows)
if apply and rows:
# Archiwum i DELETE w JEDNEJ transakcji: albo wiersz jest zapisany
# i skasowany, albo nie dzieje się nic. Odwrotna kolejność albo
# osobne transakcje mogłyby zostawić skasowane wiersze bez śladu.
archived = session.execute(text(_ARCHIVE_SQL)).rowcount or 0
deleted = session.execute(text(_DELETE_SQL)).rowcount or 0
if archived != deleted:
log.warning(
"zakres %s: zarchiwizowano %d, skasowano %d (rozjazd)",
prefix, archived, deleted,
)
total += n
log.info("zakres %s: %d przypisan%s", prefix, n, " (skasowane)" if apply else " (dry-run)")
print(f" zakres {prefix}: {n}{' skasowanych' if apply else ' do skasowania'}", flush=True)
return {"matched": total}
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
ap = argparse.ArgumentParser()
ap.add_argument("--yes", action="store_true", help="wykonaj DELETE (bez tego dry-run)")
args = ap.parse_args()
out = run(apply=args.yes)
print(f"\nRAZEM: {out['matched']} {'skasowanych' if args.yes else 'do skasowania (dry-run)'}")
if __name__ == "__main__":
main()