goon/scripts/review_performer_attributions.py
goon-foss ef8c255e1f perf(scenes): indeks trigramowy na title + ranking kandydatow do przegladu
Kazde szukanie slowa w tytulach bylo sekwencyjnym skanem 3,46 mln wierszy. Blokowalo
to ranking over-attribution: metryka "ile scen ma te nazwe w tytule, ale NIE jest do
niej przypisanych" wymaga jednego skanu NA KANDYDATA, a kandydatow jest ~800. Proba
zostala ubita po 10 minutach.

GIN + gin_trgm_ops obsluguje LIKE, ILIKE i regexy, a pg_trgm sam normalizuje wielkosc
liter, wiec indeks na surowym title wystarcza. Efekt: 26 ms zamiast sekund na
zapytanie, ranking 800 kandydatow w 48 sekund zamiast godzin. Indeks 315 MB.

Zbudowany CONCURRENTLY w autocommit_block: zwykle CREATE INDEX trzymaloby lock
blokujacy zapisy na scenes przez cala budowe i zatrzymalo ingest.

Ranking od razu pokazuje dwie rozne rzeczy. Czyste smieci ("Pornhub" 105.9, "Monster"
92, "Pretty" 63, "Nice" 42) oraz SAME NAZWISKA (Devine, Starr, Dior, Cruz, West,
Knight, Johnson, James) - wpisy powstale z rozbicia imienia i nazwiska, ktore potem
lapia kazda scene z tym nazwiskiem, nalezaca do zupelnie innej osoby.

Usuniete przy okazji: "Pornhub" (108 scen, 0 z kanonu, wszystkie to "full video on
pornhub") i "Precious" (119 scen, 0 z kanonu, sam przymiotnik).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:59:52 +02:00

201 lines
8.5 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.

"""Przegląd i przycinanie przypisań JEDNEGO performera (wzorzec „Rapture").
Dotyczy klasy, której NIE DA SIĘ posprzątać hurtowo: realna osoba (jest w TPDB/StashDB)
o nazwie będącej pospolitym słowem, do której tube-search dokleja wszystko, co ma to
słowo w tytule. „Rapture" miała 30 z 72 przypisań fałszywych — mainstreamowy film „The
Rapture" z Mimi Rogers, literówkę „hymen raptured", tytuły scen („LucidFlix Tru Kait
Rapture") i zwykłe użycia („complete rapture", „moments of rapture").
**Dlaczego ręcznie.** Przetestowałem pięć sygnałów automatycznych i żaden nie rozstrzyga:
- sama provenance ze scrapera — realni twórcy spoza TPDB (Amouranth, MollyRedWolf)
też mają 100% przypisań ze scrapera
- obsada z kanonu na scenie — backfill 0026 wypełnił tylko sceny jednoźródłowe, więc
sceny mieszane, w których leży problem, mają NULL (1 trafienie na 560 tys.)
- udział nazwy w tytule — u prawdziwych niszowych osób też ~100% (Amouranth 100%,
Clanddi 100%)
- „obce" sceny ze słowem w tytule — myli generyczne użycie z pominiętą atrybucją
(Amouranth ma 137 takich, a to po prostu jej sceny bez przypisanej obsady)
- stosunek tag/performer ≥1 — wyklucza 79 pozycji, ale samej Rapture BY NIE ZŁAPAŁ
Zostaje przeczytanie tytułów. Ten skrypt robi z tego operację na kilka minut zamiast
pół godziny: wypisuje komplet z kontekstem, przyjmuje listę prefiksów do odpięcia i
domyślnie NIE rusza przypisań potwierdzonych przez kanon.
Skala problemu (2026-07-27): 1335 kandydatów z ≥20 scenami, 539 z ≥100 (105 tys.
przypisań). Nie ma sensu przerabiać ich wszystkich — używaj, gdy konkretna osoba
zwróci uwagę, tak jak Rapture wyszła przy audycie phashy.
**Wskaźnik pospolitości** liczony przy każdym przeglądzie: ile scen ma tę nazwę w
tytule, ale NIE jest do niej przypisanych, podzielone przez liczbę jej przypisań.
Addiction 14,3 Rogue 5,0 Mystery 4,3 Gypsy 3,2 Precious 3,1 Rapture 2,5
Kwini 1,8 Chyna 0,8 Amouranth 0,45
Wskaźnik NIE mówi, czy osoba jest prawdziwa — mówi tylko, że jej przypisania są
niewiarygodne i trzeba je przeczytać. Rapture przy 2,5 okazała się realna (zostały 42
sceny), „Precious" przy 3,1 okazała się wyłącznie przymiotnikiem („precious
stepdaughter", „her ass is precious") i poszła w całości.
`--candidates` daje ranking, kogo przejrzeć. Działa dzięki indeksowi trigramowemu
(migracja 0027) — przedtem pojedyncze szukanie słowa w tytułach było skanem 3,46 mln
wierszy, więc ranking ~800 kandydatów zajmował godziny i został ubity. Teraz jedno
zapytanie to ~26 ms.
Użycie (kontener worker):
python -m scripts.review_performer_attributions --candidates
python -m scripts.review_performer_attributions "Rapture"
python -m scripts.review_performer_attributions "Rapture" --detach-file /tmp/zle.txt
python -m scripts.review_performer_attributions "Rapture" --detach-file /tmp/zle.txt --yes
`--detach-file` to zwykły plik tekstowy: jedna linia = początek tytułu (bez wielkości
liter). Puste linie i `#` pomijane.
"""
from __future__ import annotations
import argparse
import sys
from sqlalchemy import text
from app.db import session_scope
_LIST_SQL = """
SELECT sc.title,
sc.duration_sec,
coalesce(src.kind::text, '?') AS zrodlo,
(SELECT count(*) FROM scene_performers x WHERE x.scene_id = sc.id) AS ilu_perf,
sc.id
FROM scene_performers sp
JOIN scenes sc ON sc.id = sp.scene_id
LEFT JOIN sources src ON src.id = sp.source_id
WHERE sp.performer_id = :pid
ORDER BY lower(sc.title)
"""
_FOREIGN_SQL = """
SELECT count(*) FROM scenes s
WHERE s.title ~* ('\\m' || :name || '\\M')
AND NOT EXISTS (SELECT 1 FROM scene_performers sp
WHERE sp.scene_id = s.id AND sp.performer_id = :pid)
"""
# UWAGA: `ORDER BY 3::numeric / scen` NIE działa — po rzutowaniu Postgres traktuje
# trójkę jako literał, nie numer kolumny, i sortuje po 3/scen (czyli po liczbie scen
# rosnąco). Wyrażenie trzeba powtórzyć jawnie, stąd osobne CTE.
_CANDIDATES_SQL = """
WITH kand AS (
SELECT pf.id, pf.canonical_name,
(SELECT count(*) FROM scene_performers sp WHERE sp.performer_id = pf.id) AS scen
FROM performers pf
WHERE pf.canonical_name !~ ' ' AND length(pf.canonical_name) >= 4
), z AS (
SELECT k.canonical_name, k.scen,
(SELECT count(*) FROM scenes s
WHERE s.title ~* ('\\m' || k.canonical_name || '\\M')
AND NOT EXISTS (SELECT 1 FROM scene_performers sp
WHERE sp.scene_id = s.id AND sp.performer_id = k.id)) AS obce
FROM kand k WHERE k.scen >= :min_scenes
)
SELECT canonical_name, scen, obce FROM z
ORDER BY obce::numeric / scen DESC
LIMIT :limit
"""
def _find_performer(session, name: str):
row = session.execute(
text(
"SELECT id, canonical_name FROM performers "
"WHERE lower(canonical_name) = lower(:n) ORDER BY id LIMIT 1"
),
{"n": name},
).first()
return row
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("performer", nargs="?", help="canonical_name performera")
ap.add_argument("--candidates", action="store_true", help="ranking: kogo przejrzeć")
ap.add_argument("--min-scenes", type=int, default=60)
ap.add_argument("--limit", type=int, default=25)
ap.add_argument("--detach-file", help="plik z prefiksami tytułów do odpięcia")
ap.add_argument("--yes", action="store_true", help="wykonaj odpięcie (bez tego dry-run)")
ap.add_argument(
"--include-canonical",
action="store_true",
help="pozwól odpiąć też przypisania z tpdb/stashdb (domyślnie chronione)",
)
args = ap.parse_args()
if args.candidates:
with session_scope() as s:
rows = s.execute(
text(_CANDIDATES_SQL),
{"min_scenes": args.min_scenes, "limit": args.limit},
).fetchall()
print("nazwa sceny obce wskaźnik")
for nazwa, scen, obce in rows:
print(f" {nazwa[:20]:22} {scen:5} {obce:7} {obce/scen:7.1f}")
print("\nWskaźnik = obce sceny ze słowem w tytule / przypisania tej osoby.")
print("Wysoki = nazwa jest zwykłym słowem; NIE znaczy, że osoba jest fikcyjna.")
return
if not args.performer:
print("podaj canonical_name performera albo --candidates")
sys.exit(1)
prefixes: list[str] = []
if args.detach_file:
with open(args.detach_file, encoding="utf-8") as fh:
prefixes = [
ln.strip().lower()
for ln in fh
if ln.strip() and not ln.lstrip().startswith("#")
]
with session_scope() as s:
perf = _find_performer(s, args.performer)
if perf is None:
print(f"nie znalazłem performera: {args.performer!r}")
sys.exit(1)
pid, name = perf[0], perf[1]
rows = s.execute(text(_LIST_SQL), {"pid": pid}).fetchall()
obce = int(s.execute(text(_FOREIGN_SQL), {"name": name, "pid": pid}).scalar_one())
wsk = obce / len(rows) if rows else 0.0
print(f"{name}{len(rows)} przypisań, obcych scen ze słowem: {obce}, "
f"wskaźnik pospolitości: {wsk:.1f}")
print("(wysoki = nazwa jest zwykłym słowem, przypisania trzeba przeczytać)\n")
hit_ids: list[str] = []
for title, dur, zrodlo, ilu, sid in rows:
t = (title or "").lower()
matched = any(t.startswith(p) for p in prefixes)
protected = matched and zrodlo in ("tpdb", "stashdb") and not args.include_canonical
if matched and not protected:
hit_ids.append(sid)
mark = "ODPNIJ" if (matched and not protected) else ("CHRONIONE" if protected else " ")
w_tytule = "T" if name.lower() in t else "-"
print(f" {mark:10} [{zrodlo:8}] nazwa-w-tytule={w_tytule} perf={ilu} "
f"{dur or '?'}s {(title or '')[:58]!r}")
if not prefixes:
print("\n(sam przegląd — podaj --detach-file, żeby coś odpiąć)")
return
print(f"\ndo odpięcia: {len(hit_ids)} / {len(rows)}")
if not args.yes:
print("(dry-run — uruchom z --yes)")
return
n = s.execute(
text("DELETE FROM scene_performers WHERE performer_id = :pid AND scene_id = ANY(:ids)"),
{"pid": pid, "ids": hit_ids},
).rowcount
s.commit()
print(f"ODPIĘTE: {n}")
if __name__ == "__main__":
main()