goon/app/resolve/movie_score.py
goon-foss c4845e9f61 fix(unmerge): szukaj istniejacego filmu przed utworzeniem, sprzataj duble, dziedzicz created_at
Trzy skutki pierwszego przebiegu rozdzielania (2026-09-02, 5846 nowych filmow), oba
zgloszone z apki tego samego dnia:

1. Duble (a5cdda7f). Klaster poza kotwica dostawal ZAWSZE nowy film, choc "Control 2"
   z innego zrodla juz istnial: 798 nowych o tytule sprzed przebiegu, 408 dubli miedzy
   soba. Teraz najpierw `find_movie_candidates` + `score_movie_candidate` (ten sam
   resolver co ingest) i przy >= 0.85 refy ida do istniejacego. Tryb
   `--dedupe-created-after <ts>` scala juz utworzone przez `merge_movies` (691 scalonych).

2. Scorer przepuszczal jeszcze pary z roznymi slowami po obu stronach: "hardcore
   fucking sluts 2" vs "foot fucking sluts 2", "santa list gangbang" vs "gangbang d"
   (token_set 85-94, ze zgodnym studiem i rokiem auto-merge). Lustra nie zmieniaja
   tytulow, wiec: rowne zbiory slow po zdjeciu wypelniaczy SEO = 1.0, podzbior albo
   rozne slowa po obu stronach = sufit 0.6. Ogonki po apostrofie sklejane ("she s" ->
   "shes"), bo normalize() tnie apostrof na spacje. Lista dubli spadla z 941 do 691 i
   sa to same identyczne tytuly.

3. Falszywe "+N nowych" (1db95176). Nowy film dostawal created_at = teraz, a badge w
   ulubionych od lipca sumuje sceny i FILMY: 2196 filmow z obsada z dzisiejsza data
   zapalilo "+N" u kazdego z performerem w ulubionych, przy zerze nowych scen na liscie.
   Teraz created_at = pierwsze zobaczenie refow klastra; 5231 juz utworzonych
   poprawione w bazie tym samym kryterium.
2026-09-02 15:29:21 +02:00

253 lines
9.9 KiB
Python

"""Composite scoring dla movies, title + year + studio + cast Jaccard.
Movies vs Scenes scoring:
- Movies rzadko mają fingerprinty, tytuł jest najsilniejszym sygnałem (weight 0.5).
- Year (production year) ważny ale czasem nieznany (paradisehill często NULL); wagę
redystrybuujemy proporcjonalnie gdy brak.
- Studio: binary signal, match=1.0, mismatch=0.0; gdy któraś strona ma NULL, sygnał
pomijamy (None waga).
- Cast: Jaccard po canonical UUID-ach performerów. Bonus signal, niektóre source'y
(paradisehill często, dooplay zawsze) mają cast; tube'y bez cast → None.
Threshold (lower niż scenes, title dla movies ma niższy false-positive rate):
≥0.85 auto-merge
0.65-0.85 review
<0.65 nowy kanon
"""
from __future__ import annotations
import uuid
from collections.abc import Iterable
from dataclasses import dataclass
from rapidfuzz import fuzz
from sqlalchemy.orm import Session
from app.models.movie import Movie, MoviePerformer
@dataclass
class MovieScoreBreakdown:
title: float | None = None
year: float | None = None
studio_match: bool | None = None
cast: float | None = None
composite: float = 0.0
reasons: dict | None = None
def to_dict(self) -> dict:
return {
"title": self.title,
"year": self.year,
"studio_match": self.studio_match,
"cast": self.cast,
"composite": self.composite,
"reasons": self.reasons or {},
}
_BASE_WEIGHTS = {
"title": 0.50,
"year": 0.20,
"studio": 0.20,
"cast": 0.10,
}
# Movies-specific thresholds (lower niż scenes ze względu na unique titles)
AUTO_THRESHOLD = 0.85
REVIEW_THRESHOLD = 0.65
def _series_number(title_normalized: str) -> int | None:
"""Ostatnia samodzielna liczba w tytule, z pominieciem lat (1900-2099).
"her way to use tinder 61" -> 61, "the doctor is in 2" -> 2, "the doctor" -> None,
"cece 2019" -> None (rok to nie numer odcinka).
"""
liczby = [
int(t)
for t in title_normalized.split()
if t.isdigit() and not 1900 <= int(t) <= 2099
]
return liczby[-1] if liczby else None
# Tyle najwyzej dostaje tytul, gdy numer serii ma tylko jedna strona. Ponizej progu
# auto-merge, zeby sam tytul nie decydowal, ale dosc, zeby ze zgodnym studiem i rokiem
# trafic do recznego przegladu zamiast od razu odpadac.
_ONE_SIDED_NUMBER_CAP = 0.6
# Slowa, ktore lustra dooplay dokladaja do tytulu w SEO ("Watch Cece Adult Movie Online
# Free"). Nie niosa tresci, wiec przy tescie podzbioru je pomijamy.
_SEO_WYPELNIACZE = frozenset({
"watch", "adult", "xxx", "movie", "movies", "film", "online", "free", "full",
"porn", "hd", "stream", "streaming", "the", "a", "an", "and", "of",
})
def _title_signal(cand_title: str, inc_title: str) -> tuple[float, bool, dict]:
"""(ocena tytulu 0..1, twarde odrzucenie, powody).
`token_set_ratio` mierzy czesc wspolna, wiec tytul bedacy podzbiorem drugiego dostaje
pelne 100: "the doctor" vs "the doctor is in 2", "fantasies" vs "dark fantasies 3".
Numerowane serie wychodza na 90-96%, tez ponad progiem auto-merge. Gdy zrodlo nie ma
roku ani studia (lustra dooplay czesto nie maja), o wszystkim decyduje sam tytul i
cala seria zapada sie w jeden wpis. Zgloszenie 32100b94 (2026-08-30): pod "The Doctor"
siedzialo 9 roznych filmow z pandamovies, w tym granny porn; w bazie 1878 filmow ma
co najmniej dwie rozne strony z pandamovies, a jedna to norma.
Zasady, obie oparte na numerze serii (ostatnia liczba w tytule, bez lat):
* oba tytuly maja numer i sie rozni -> twarde odrzucenie (to inny odcinek),
* numer ma tylko jedna strona -> tytul najwyzej 0.6 (inne wydanie, nie lustro).
Sam podzbior bez numerow zostaje bez kary celowo: "cece" vs "watch cece adult" to
prawdziwe lustro z przedrostkiem SEO i to wlasnie dla niego jest token_set.
"""
ocena = fuzz.token_set_ratio(cand_title, inc_title) / 100.0
powody: dict = {}
n_c, n_i = _series_number(cand_title), _series_number(inc_title)
if n_c is not None and n_i is not None and n_c != n_i:
powody["series_number_mismatch_reject"] = [n_c, n_i]
return ocena, True, powody
if (n_c is None) != (n_i is None):
powody["series_number_one_sided_cap"] = n_c if n_c is not None else n_i
return min(ocena, _ONE_SIDED_NUMBER_CAP), False, powody
# Podzbior z PRAWDZIWYMI dodatkowymi slowami to inny film, nie lustro. Po
# normalizacji "The Doctor" zostaje samym "doctor", a jedno slowo jest podzbiorem
# kazdego tytulu z tym slowem: "granny s doctor games", "we need a doctor", "down on
# the doctor" dostawaly 100 i lecialy do kotwicy (rozdzielanie 2026-09-02). Wyjatek to
# przedrostki SEO luster ("watch cece adult" = "cece"), stad najpierw zdejmujemy
# slownik wypelniaczy i dopiero porownujemy zbiory slow.
ta = _slowa(cand_title)
ti = _slowa(inc_title)
if ta and ti and ta == ti:
# Te same slowa (po zdjeciu wypelniaczy i sklejeniu apostrofow): prawdziwe lustro,
# niezaleznie od kolejnosci i przedrostkow SEO.
return 1.0, False, powody
if ta and ti and (ta < ti or ti < ta):
powody["subset_title_cap"] = sorted((ta ^ ti))[:4]
return min(ocena, _ONE_SIDED_NUMBER_CAP), False, powody
if ta and ti:
# Kazda strona ma slowo, ktorego druga nie ma: dla filmow to prawie zawsze inny
# tytul, a nie literowka. `token_set_ratio` dawal tu 85-94 ("hardcore fucking sluts
# 2" vs "foot fucking sluts 2", "santa list gangbang" vs "gangbang d") i przy
# zgodnym studiu i roku wychodzil auto-merge (sprzatanie dubli 2026-09-02).
powody["distinct_words_cap"] = sorted(ta ^ ti)[:4]
return min(ocena, _ONE_SIDED_NUMBER_CAP), False, powody
return ocena, False, powody
def _slowa(title_normalized: str) -> frozenset[str]:
"""Zbior slow tytulu do porownania: bez wypelniaczy SEO, z doklejonymi ogonkami po
apostrofie ("she s" -> "shes", "gangbang d" -> "gangbangd"), bo `normalize()` tnie
apostrof na spacje i "She's In Control 3" rozjezdzalo sie z "Shes In Control 3"."""
out: list[str] = []
for tok in title_normalized.split():
if len(tok) == 1 and tok.isalpha() and out:
out[-1] = out[-1] + tok
else:
out.append(tok)
return frozenset(out) - _SEO_WYPELNIACZE
def score_movie_candidate(
session: Session,
*,
candidate: Movie,
norm_title_normalized: str,
norm_release_year: int | None,
norm_studio_id: uuid.UUID | None,
norm_performer_ids: Iterable[uuid.UUID],
) -> MovieScoreBreakdown:
"""Liczy score dopasowania incoming → candidate. Wszystko w [0,1]."""
breakdown = MovieScoreBreakdown(reasons={})
# Title (always)
series_reject = False
if candidate.title_normalized and norm_title_normalized:
breakdown.title, series_reject, powody = _title_signal(
candidate.title_normalized, norm_title_normalized
)
breakdown.reasons.update(powody)
# Year, exact lub ±1 = wysoka, ±2 = średnia
if candidate.release_year is not None and norm_release_year is not None:
delta = abs(candidate.release_year - norm_release_year)
if delta == 0:
breakdown.year = 1.0
elif delta == 1:
breakdown.year = 0.7
elif delta == 2:
breakdown.year = 0.4
else:
breakdown.year = 0.0
# Studio match, binary
if candidate.studio_id and norm_studio_id:
breakdown.studio_match = candidate.studio_id == norm_studio_id
# Cast Jaccard, gdy obie strony mają performerów
norm_perf_set = {str(pid) for pid in norm_performer_ids if pid is not None}
if norm_perf_set:
cand_perf_rows = session.execute(
MoviePerformer.__table__.select().where(
MoviePerformer.movie_id == candidate.id
)
).fetchall()
cand_perf_set = {str(r.performer_id) for r in cand_perf_rows}
if cand_perf_set:
inter = norm_perf_set & cand_perf_set
union = norm_perf_set | cand_perf_set
breakdown.cast = len(inter) / len(union) if union else 0.0
# Composite, redystrybuuj wagi po dostępnych sygnałach
available: dict[str, float] = {}
if breakdown.title is not None:
available["title"] = breakdown.title
if breakdown.year is not None:
available["year"] = breakdown.year
if breakdown.studio_match is not None:
available["studio"] = 1.0 if breakdown.studio_match else 0.0
if breakdown.cast is not None:
available["cast"] = breakdown.cast
if not available:
breakdown.composite = 0.0
breakdown.reasons["no_signals"] = True
return breakdown
weights = {k: _BASE_WEIGHTS[k] for k in available}
total_w = sum(weights.values())
if total_w == 0.0:
breakdown.composite = 0.0
return breakdown
norm_w = {k: w / total_w for k, w in weights.items()}
score = sum(available[k] * norm_w[k] for k in available)
# Hard reject: studio mismatch + year mismatch (delta ≥3) → różne filmy z podobnym
# tytułem. "Tushy Anal Bliss 7" vs "Anal Bliss 7" z innego studia rok różny, to NIE
# jest mirror, to różne wydania.
if (
breakdown.studio_match is False
and breakdown.year is not None
and breakdown.year < 0.4
):
breakdown.reasons["studio_year_mismatch_reject"] = True
score = 0.0
# Hard reject: rozne numery serii. Bez tego "Her way to use Tinder 61" wchlaniala
# "... Tinder 60" (token_set 96%), az pod jednym tytulem siedzialy 102 strony.
if series_reject:
score = 0.0
breakdown.composite = max(0.0, min(1.0, score))
breakdown.reasons["weights"] = norm_w
breakdown.reasons["sub_scores"] = available
return breakdown
def triage_movie(score: float) -> str:
if score >= AUTO_THRESHOLD:
return "auto"
if score >= REVIEW_THRESHOLD:
return "review"
return "reject"