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.
208 lines
8.8 KiB
Python
208 lines
8.8 KiB
Python
"""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()
|