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).
104 lines
4.7 KiB
Python
104 lines
4.7 KiB
Python
"""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
|
||
)
|