goon/app/models/clip_feed.py
goon-foss 0df2236b37 feat(clips): feed Swipe z afinicznoscia, pamiecia widzianych i telemetria (etap 3)
Tabele per urzadzenie (migracja 0037): clip_seen (wykluczenie, wiersz przy
podaniu klipu), clip_feed_events (append-only: impression z dwell, skip,
complete, like, unlike, not_interested, autoswipe), clip_affinity (wagi
tag/nisza/tworca w JSONB), clip_favorites, clip_blacklist. GIN na clips.niches.

GET /clips/feed: kursor base64 {seed, pk, page} zamiast offsetu; 80% z puli
afinicznosci (skany po tagu/niszy/tworcy od losowego uuid, punktacja
sum wag + 0,3 ln(1+lajki) + swiezosc, max 2 klipy tworcy na strone) + 20%
explore (okno 2000 id po PK, losowanie wazone lajkami, zawijanie na koncu).
Bez wag 100% explore. POST /clips/events: zapis, podniesienie outcome,
ulubione, delty wag (like +5, complete +3, dwell>=10 s +1, skip<2 s -1,
not_interested -4 i -8 na wskazany cel). Onboarding 3 tapow, opcje,
blacklista tag/nisza/tworca, reset feedu, ulubione. Import miedzy
urzadzeniami kopiuje clip_seen, clip_favorites i clip_blacklist.

Job clips-maintenance co 24 h: decay wag x0,97, retencja zdarzen 90 dni,
reaper (HEAD na 2000 najdawniej sprawdzanych). Logika bez bazy w
app/clips_feed.py z testami (kursor, delty, cap/decay, punktacja, sklad).
2026-09-14 20:29:49 +02:00

104 lines
4.7 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.

"""Stan feedu Swipe per urządzenie: widziane, zdarzenia, afiniczność, ulubione, blacklista.
Tożsamość = `X-Device-Id` (bez kont), jak w reszcie aplikacji. Wszystko kluczowane po
`device_id`, więc przenosi się kodem synchronizacji (`/me/import`).
**`clip_seen`**: zbiór wykluczeń feedu. Wiersz powstaje **w chwili podania** klipu
(`outcome='served'`), zdarzenia z apki podnoszą `outcome`. Złożony PK to jedyny indeks,
jakiego potrzebuje `NOT EXISTS` w zapytaniu feedu (jedno trafienie na kandydata). Koszt
zapisu przy podaniu: klip z prefetchu, którego użytkownik nie doszedł, znika z feedu
(kilka na sesję); zysk: kursor bez listy widzianych i zero powtórek między stronami.
**`clip_feed_events`**: append-only telemetria (impression z dwell, skip, complete, like,
unlike, not_interested, autoswipe). Bez FK na klip (klip może zostać skasowany). Karmi
afiniczność i mierzy, czy personalizacja coś daje (`slot` affinity vs explore).
**`clip_affinity`**: wagi per urządzenie w JSONB (tag → waga, nisza → waga, twórca → waga),
aktualizowane przy zdarzeniu, nocny decay ×0,97, cap 60/40/40 pozycji. To najbardziej
intymne dane w aplikacji: kasowalne jednym wywołaniem (`DELETE /clips/affinity`), nie
trafiają do logów.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, SmallInteger, String, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class ClipSeen(Base):
__tablename__ = "clip_seen"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
clip_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("clips.id", ondelete="CASCADE"), primary_key=True
)
#: served | skip | complete | like | not_interested (rosnąca „siła", nie cofamy).
outcome: Mapped[str] = mapped_column(String(16), nullable=False, default="served")
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
class ClipFeedEvent(Base):
__tablename__ = "clip_feed_events"
__table_args__ = (
Index("ix_clip_feed_events_device_created", "device_id", "created_at"),
Index("ix_clip_feed_events_created", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
session_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
clip_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
dwell_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
#: Powód „nie interesuje": `tag:<slug>` | `niche:<id>` | `creator:<name>` | `seen_it`.
reason: Mapped[str | None] = mapped_column(String(160), nullable=True)
#: `affinity` | `explore` (z którego koszyka klip przyszedł).
slot: Mapped[str | None] = mapped_column(String(12), nullable=True)
position: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
class ClipAffinity(Base):
__tablename__ = "clip_affinity"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
tag_weights: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
niche_weights: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
creator_weights: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
events_seen: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
class ClipFavorite(Base):
__tablename__ = "clip_favorites"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
clip_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("clips.id", ondelete="CASCADE"), primary_key=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
class ClipBlacklist(Base):
__tablename__ = "clip_blacklist"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
#: tag | niche | creator
kind: Mapped[str] = mapped_column(String(8), primary_key=True)
value: Mapped[str] = mapped_column(String(128), primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)