goon/scripts/prune_token_overlap_attributions.py
goon-foss 5217d07222 fix(search): nazwa performera musi byc w slugu SPOJNA FRAZA
Zgloszenie 2026-08-13: strona Tiffani Time pelna scen o Tiffany Tatum,
Tiffani Madison i Victorii Tiffani.

Przyczyna: `_search_base.search` dokleja szukanego performera do KAZDEJ
sceny bezwarunkowo (`all_performers = [RawPerformer(name=query), ...]`),
wiec cala poprawnosc obsady wisi na filtrze tokenow. A filtr testowal
PODCIAG na luznych tokenach, wiec:

  "tiffany" ⊂ "lp-s-tiffany-love-first-time-gonzo"   (inna osoba)
  "time"    ⊂ "adult-time", "first-time", "multiple-times"  (pospolite slowo)

Historia tej linijki to trzy poziomy: any() -> all() -> (teraz) fraza.
`all()` naprawil "Ava Koxxx na scenach Avy Devine", ale zostawil oba
powyzsze. Teraz slug musi zawierac CALA nazwe jako ciag, z granica slowa
po bokach, z dowolnym separatorem miedzy czlonami.

Dozwolone koncowe "s": slug gubi apostrof, wiec "TiffaniTime's" zapisuje
jako "tiffanitimes". Nie luzuje bramki, bo przed tym "s" stoi cala fraza.

hqporner mial WLASNA kopie tego filtru (i wlasny zestaw dziur) - podpiety
pod ta sama funkcje, zeby sie nie rozjezdzalo.

12 przypadkow testowych z realnych danych, w tym te ktore MUSZA przejsc
(tiffani-time-*, tiffanitimes-*, mia-li-* z czlonem <3 znaki).

Do tego skrypt czyszczacy juz istniejace pomylki. Kryterium zawezone po
obejrzeniu probki: samo "brak nazwy w tytule" kasowalo MESKICH performerow
(tuby nie wypisuja ich w tytule, a przypisanie szlo z listy obsady, nie
z searcha). Dolozony warunek "inny performer sceny ma dowod I dzieli
czlon" - probka 14/14 to realne pomylki.
2026-08-13 11:17:43 +02:00

130 lines
5.3 KiB
Python
Raw 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).
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__)
#: 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,
(replace(replace(replace(
lower(coalesce(ps.page_url,'') || ' ' || s.title), '-',''), '_',''), ' ','')
LIKE '%' || replace(lower(p.canonical_name),' ','') || '%') 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
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:
session.execute(
text(
"""
DELETE FROM scene_performers sp
USING (SELECT * 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)) d
WHERE sp.scene_id = d.scene_id AND sp.performer_id = d.performer_id
"""
)
)
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()