goon/app/models/clip.py
goon-foss 4666fcb04a feat(edge): nowa punktacja feedu z historii klipu u nas, 25% w grupie kontrolnej
Odtworzenie uczenia na 189 tys. zdarzen (wagi zdarzenie po zdarzeniu, bez
zagladania w przyszlosc, test na 30% najnowszych): stara punktacja porzadkowala
klipy w obrebie urzadzenia z AUC 0,576 (lajk albo >= 10 s vs ucieczka < 2 s).
Tagi RedGifs sa liczne i ogolne, wiec ich suma dominowala, a mowila malo; nisze
prawie nic, popularnosc z RedGifs slabo, tworca i przyjecie klipu u nas najwiecej.

Nowa: log(waga tagu x rzadkosc tagu) + 0,25 log(nisze) + 2,5 log(tworca)
+ 0,8 ln(1+lajki RedGifs) + 120 (odsetek lajkow u nas - 3%) - 15 (odsetek
ucieczek - 50%) + 0,3 za swiezosc. Na tescie 0,652, regresja na tych samych
cechach 0,654. Liczniki clips.feed_views/skips/likes (migracja 0052, wypelnione
z historii), bumpowane w POST /clips/events. 25% urzadzen (hash id) zostaje na
starej punktacji do porownania na zywym ruchu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:47:02 +02:00

90 lines
4.6 KiB
Python

"""Klipy do feedu Swipe (sekcja w stylu TikToka). Osobny wertykał, NIE sceny.
Klip to 5-120 s pionowego wideo od twórcy-amatora (RedGifs, Pornhub Shorties, nsfwswipe),
bez obsady z kanonu TPDB, bez dedupu ze scenami i bez filtra 60 s, którym grid ukrywa
krótkie rzeczy. Dlatego własne tabele zamiast wciskania tego w `scenes`:
inne kolumny (twórca, audio, lajki, nisze), inny cykl życia (twórcy kasują klipy, więc
reaper co dobę), inna taksonomia (tagi RedGifs to nie tagi Goona, 1495 vs nasze).
`(source, source_id)` identyfikuje klip u źródła (`redgifs` + id gifa). Adresy plików
RedGifs są publiczne i bez podpisu (`media.redgifs.com/<Id>.mp4`), więc trzymamy je
wprost i telefon gra direct, bez ekstraktora i bez VPS-a. Pornhub Shorties (etap
późniejszy) będzie miał `hd_url` puste i resolve na żądanie.
Tagi w osobnej tabeli z indeksem `(tag, clip_id)`: feed dobiera kandydatów skanem po tagu
od losowego uuid (jak `sort=random` w `list_scenes`), więc para w tej kolejności to jego
główny indeks. Nisze (społeczności RedGifs) jako tablica, bo służą tylko onboardingowi
i blacklistom, nie skanom.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPKMixin
class Clip(UUIDPKMixin, TimestampMixin, Base):
__tablename__ = "clips"
__table_args__ = (
UniqueConstraint("source", "source_id", name="uq_clips_source_source_id"),
Index("ix_clips_published_at", "published_at"),
Index("ix_clips_creator", "creator"),
)
#: `redgifs` | `pornhub` | `nsfwswipe`
source: Mapped[str] = mapped_column(String(16), nullable=False)
source_id: Mapped[str] = mapped_column(String(64), nullable=False)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
#: Pseudonim twórcy u źródła (RedGifs `userName`). Nie łączymy z `performers`.
creator: Mapped[str | None] = mapped_column(String(128), nullable=True)
creator_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
duration_sec: Mapped[float | None] = mapped_column(Float, nullable=True)
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
has_audio: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
#: RedGifs `contentType`, np. "Solo Female".
content_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
niches: Mapped[list[str]] = mapped_column(ARRAY(String(64)), nullable=False, default=list)
hd_url: Mapped[str | None] = mapped_column(Text, nullable=True)
sd_url: Mapped[str | None] = mapped_column(Text, nullable=True)
poster_url: Mapped[str | None] = mapped_column(Text, nullable=True)
thumb_url: Mapped[str | None] = mapped_column(Text, nullable=True)
#: Źródło deklaruje wariant HLS (RedGifs `hls`). Informacyjne, gramy mp4.
hls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
views: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
#: Przyjecie klipu w samym Goonie (feed Edge): wyswietlenia, ucieczki < 2 s, lajki.
#: Licznik bumpuje POST /clips/events, punktacja feedu z tego korzysta (migracja 0052).
feed_views: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
feed_skips: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
feed_likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
#: Data publikacji u źródła (RedGifs `createDate`), do odznaki „nowe" i sortu.
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
dead_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
dead_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
class ClipTag(Base):
__tablename__ = "clip_tags"
__table_args__ = (Index("ix_clip_tags_tag_clip", "tag", "clip_id"),)
clip_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("clips.id", ondelete="CASCADE"), primary_key=True
)
#: Slug tagu u źródła, małe litery, spacje jako myślniki ("big-tits").
tag: Mapped[str] = mapped_column(String(64), primary_key=True)