goon/alembic/versions/20260914_0037_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

86 lines
4.2 KiB
Python

"""Feed Swipe, stan per urządzenie: clip_seen, clip_feed_events, clip_affinity,
clip_favorites, clip_blacklist. Plus GIN na clips.niches pod skan po niszy.
Uzasadnienie w docstringu `app/models/clip_feed.py`.
Revision ID: 0037_clip_feed
Revises: 0036_clips
Create Date: 2026-09-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0037_clip_feed"
down_revision: str | None = "0036_clips"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"clip_seen",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("outcome", sa.String(16), nullable=False, server_default="served"),
sa.Column("seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["clip_id"], ["clips.id"], name="fk_clip_seen_clip_id_clips", ondelete="CASCADE"),
sa.PrimaryKeyConstraint("device_id", "clip_id", name="pk_clip_seen"),
)
op.create_table(
"clip_feed_events",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("session_id", sa.String(32), nullable=True),
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("kind", sa.String(16), nullable=False),
sa.Column("dwell_ms", sa.Integer(), nullable=True),
sa.Column("reason", sa.String(160), nullable=True),
sa.Column("slot", sa.String(12), nullable=True),
sa.Column("position", sa.SmallInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id", name="pk_clip_feed_events"),
)
op.create_index("ix_clip_feed_events_device_created", "clip_feed_events", ["device_id", "created_at"])
op.create_index("ix_clip_feed_events_created", "clip_feed_events", ["created_at"])
op.create_table(
"clip_affinity",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("tag_weights", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("niche_weights", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("creator_weights", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("events_seen", sa.Integer(), nullable=False, server_default="0"),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("device_id", name="pk_clip_affinity"),
)
op.create_table(
"clip_favorites",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["clip_id"], ["clips.id"], name="fk_clip_favorites_clip_id_clips", ondelete="CASCADE"),
sa.PrimaryKeyConstraint("device_id", "clip_id", name="pk_clip_favorites"),
)
op.create_table(
"clip_blacklist",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("kind", sa.String(8), nullable=False),
sa.Column("value", sa.String(128), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("device_id", "kind", "value", name="pk_clip_blacklist"),
)
# Skan kandydatów po niszy (`niches && ARRAY[...]`) bez pełnego przebiegu tabeli.
op.execute("CREATE INDEX IF NOT EXISTS ix_clips_niches_gin ON clips USING gin (niches)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_clips_niches_gin")
op.drop_table("clip_blacklist")
op.drop_table("clip_favorites")
op.drop_table("clip_affinity")
op.drop_index("ix_clip_feed_events_created", table_name="clip_feed_events")
op.drop_index("ix_clip_feed_events_device_created", table_name="clip_feed_events")
op.drop_table("clip_feed_events")
op.drop_table("clip_seen")