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).
This commit is contained in:
goon-foss 2026-09-14 20:29:49 +02:00
parent cd4803ed51
commit 0df2236b37
12 changed files with 1213 additions and 111 deletions

View file

@ -0,0 +1,86 @@
"""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")

View file

@ -1,27 +1,51 @@
"""Klipy (feed Swipe), etap 2: podstawowy listing i statystyki do pilota.
"""Klipy (feed Swipe): feed z afinicznością, pamięć widzianych, telemetria, ulubione,
blacklista, onboarding. Logika bez bazy w `app/clips_feed.py`.
Właściwy feed z afinicznością, pamięcią widzianych i telemetrią to etap 3
(`/clips/feed`, `/clips/events`). Tu tylko to, czego trzeba, żeby zweryfikować ingest
i odtwarzanie z apki/emulatora: lista najnowszych żywych klipów z filtrem po tagu,
źródle i audio oraz zbiorcze liczniki.
**Feed** `GET /clips/feed`: strona = 80 % z puli afiniczności (tagi, nisze, twórcy z wag
urządzenia) + 20 % explore (okno po kluczu głównym od kursora, losowanie ważone lajkami).
Bez wag (nowe urządzenie, brak onboardingu) 100 % explore. Każdy podany klip ląduje
w `clip_seen` od razu, więc nigdy nie wraca. Widoczność: żywy, do `clips_feed_max_duration_sec`,
z plikiem, nie widziany, nie na blackliście (tag / nisza / twórca).
**Zdarzenia** `POST /clips/events`: paczka z apki (co ~5 s). Zapis do `clip_feed_events`,
podniesienie `clip_seen.outcome`, ulubione przy like/unlike, delty na wagi afiniczności
(`clips_feed.apply_event`).
Adresy plików i posterów są publiczne (`media.redgifs.com`), więc jadą do apki wprost,
bez image proxy: RedGifs nie sprawdza Referera, a proxy tylko dodałoby VPS do toru.
"""
from __future__ import annotations
import logging
import time
import uuid
from datetime import datetime
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, Field
from sqlalchemy import func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app import clips_feed as cf
from app.api.device import get_device_id
from app.auth import require_api_key
from app.config import get_settings
from app.db import get_session
from app.models.clip import Clip, ClipTag
from app.models.clip_feed import ClipAffinity, ClipBlacklist, ClipFavorite, ClipFeedEvent, ClipSeen
log = logging.getLogger(__name__)
router = APIRouter(prefix="/clips", tags=["clips"], dependencies=[Depends(require_api_key)])
_EXPLORE_WINDOW = 2000
_AFFINITY_TAGS, _AFFINITY_NICHES, _AFFINITY_CREATORS = 6, 3, 3
_PER_TAG, _PER_NICHE, _PER_CREATOR = 60, 60, 40
_UUID_ZERO = uuid.UUID(int=0)
_BLACKLIST_KINDS = frozenset({"tag", "niche", "creator"})
# ---- schematy ---------------------------------------------------------------------------
class ClipOut(BaseModel):
id: uuid.UUID
source: str
@ -43,6 +67,7 @@ class ClipOut(BaseModel):
likes: int
views: int
published_at: datetime | None
is_favorite: bool = False
class ClipListOut(BaseModel):
@ -63,6 +88,66 @@ class ClipStatsOut(BaseModel):
tags: int
class FeedItemOut(BaseModel):
clip: ClipOut
#: `affinity` | `explore`, do telemetrii i do pomiaru, czy personalizacja coś daje.
slot: str
is_fresh: bool
class FeedPageOut(BaseModel):
items: list[FeedItemOut]
next_cursor: str
seed: str
#: True gdy okno explore doszło do końca katalogu i zawinęło się na początek.
wrapped: bool
#: Ile klipów to urządzenie już widziało (pigułka „N nowych").
served_total: int
personalized: bool
class FeedEventIn(BaseModel):
clip_id: uuid.UUID
kind: str = Field(min_length=1, max_length=16)
dwell_ms: int | None = Field(default=None, ge=0, le=3_600_000)
reason: str | None = Field(default=None, max_length=160)
slot: str | None = Field(default=None, max_length=12)
position: int | None = Field(default=None, ge=0, le=32767)
class FeedEventsIn(BaseModel):
session_id: str | None = Field(default=None, max_length=32)
events: list[FeedEventIn] = Field(max_length=100)
class OnboardingIn(BaseModel):
tags: list[str] = Field(default_factory=list, max_length=20)
niches: list[str] = Field(default_factory=list, max_length=20)
class OnboardingOptionOut(BaseModel):
value: str
count: int
class OnboardingOptionsOut(BaseModel):
tags: list[OnboardingOptionOut]
niches: list[OnboardingOptionOut]
class AffinityOut(BaseModel):
tags: dict[str, float]
niches: dict[str, float]
creators: dict[str, float]
events_seen: int
class BlacklistItemOut(BaseModel):
kind: str
value: str
# ---- pomocnicze -------------------------------------------------------------------------
def _tags_for(session: Session, clip_ids: list[uuid.UUID]) -> dict[uuid.UUID, list[str]]:
if not clip_ids:
return {}
@ -74,9 +159,451 @@ def _tags_for(session: Session, clip_ids: list[uuid.UUID]) -> dict[uuid.UUID, li
return out
def _favorites_for(session: Session, device_id: str, clip_ids: list[uuid.UUID]) -> set[uuid.UUID]:
if not clip_ids:
return set()
return {
r[0]
for r in session.execute(
select(ClipFavorite.clip_id).where(ClipFavorite.device_id == device_id, ClipFavorite.clip_id.in_(clip_ids))
)
}
def _clip_out(r: Clip, tags: list[str], fav: bool = False) -> ClipOut:
return ClipOut(
id=r.id,
source=r.source,
source_id=r.source_id,
title=r.title,
creator=r.creator,
creator_verified=r.creator_verified,
duration_sec=r.duration_sec,
width=r.width,
height=r.height,
has_audio=r.has_audio,
content_type=r.content_type,
niches=list(r.niches or []),
tags=tags,
hd_url=r.hd_url,
sd_url=r.sd_url,
poster_url=r.poster_url,
thumb_url=r.thumb_url,
likes=r.likes,
views=r.views,
published_at=r.published_at,
is_favorite=fav,
)
def _load_affinity(session: Session, device_id: str) -> tuple[ClipAffinity | None, cf.Affinity]:
row = session.get(ClipAffinity, device_id)
if row is None:
return None, cf.Affinity()
return row, cf.Affinity(
tags=dict(row.tag_weights or {}), niches=dict(row.niche_weights or {}), creators=dict(row.creator_weights or {})
)
def _save_affinity(session: Session, device_id: str, row: ClipAffinity | None, aff: cf.Affinity, events: int) -> None:
if row is None:
row = ClipAffinity(device_id=device_id)
session.add(row)
row.tag_weights = aff.tags
row.niche_weights = aff.niches
row.creator_weights = aff.creators
row.events_seen = (row.events_seen or 0) + events
def _blacklist(session: Session, device_id: str) -> dict[str, set[str]]:
out: dict[str, set[str]] = {"tag": set(), "niche": set(), "creator": set()}
for kind, value in session.execute(
select(ClipBlacklist.kind, ClipBlacklist.value).where(ClipBlacklist.device_id == device_id)
):
out.setdefault(kind, set()).add(value)
return out
def _visible_clauses(device_id: str, bl: dict[str, set[str]]) -> list:
clauses = [
Clip.dead_at.is_(None),
Clip.duration_sec.is_not(None),
Clip.duration_sec <= get_settings().clips_feed_max_duration_sec,
(Clip.hd_url.is_not(None)) | (Clip.sd_url.is_not(None)),
~select(ClipSeen.clip_id)
.where(ClipSeen.device_id == device_id, ClipSeen.clip_id == Clip.id)
.exists(),
]
if bl["creator"]:
clauses.append((Clip.creator.is_(None)) | (~Clip.creator.in_(list(bl["creator"]))))
if bl["niche"]:
clauses.append(~Clip.niches.overlap(list(bl["niche"])))
if bl["tag"]:
clauses.append(
~select(ClipTag.clip_id)
.where(ClipTag.clip_id == Clip.id, ClipTag.tag.in_(list(bl["tag"])))
.exists()
)
return clauses
def _candidates(session: Session, ids: list[uuid.UUID], clauses: list) -> list[cf.Candidate]:
if not ids:
return []
rows = session.execute(select(Clip).where(Clip.id.in_(ids), *clauses)).scalars().all()
tags = _tags_for(session, [r.id for r in rows])
return [
cf.Candidate(
id=r.id, creator=r.creator, likes=r.likes, published_at=r.published_at,
tags=tags.get(r.id, []), niches=list(r.niches or []),
)
for r in rows
]
def _explore_window(session: Session, pk: uuid.UUID) -> tuple[list[uuid.UUID], bool]:
"""Kolejne `_EXPLORE_WINDOW` id po `pk`; gdy okno się kończy, zawija do zera."""
ids = [
r[0]
for r in session.execute(
select(Clip.id).where(Clip.id > pk, Clip.dead_at.is_(None)).order_by(Clip.id).limit(_EXPLORE_WINDOW)
)
]
wrapped = False
if len(ids) < _EXPLORE_WINDOW and pk != _UUID_ZERO:
wrapped = True
more = [
r[0]
for r in session.execute(
select(Clip.id).where(Clip.dead_at.is_(None)).order_by(Clip.id).limit(_EXPLORE_WINDOW - len(ids))
)
]
ids.extend(more)
return ids, wrapped
def _affinity_candidate_ids(session: Session, aff: cf.Affinity, rng) -> set[uuid.UUID]:
ids: set[uuid.UUID] = set()
for tag in aff.top("tags", _AFFINITY_TAGS):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(ClipTag.clip_id).where(ClipTag.tag == tag, ClipTag.clip_id > start).order_by(ClipTag.clip_id).limit(_PER_TAG)
).all()
if len(rows) < _PER_TAG:
rows += session.execute(
select(ClipTag.clip_id).where(ClipTag.tag == tag).order_by(ClipTag.clip_id).limit(_PER_TAG - len(rows))
).all()
ids.update(r[0] for r in rows)
for niche in aff.top("niches", _AFFINITY_NICHES):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(Clip.id).where(Clip.niches.overlap([niche]), Clip.id > start).order_by(Clip.id).limit(_PER_NICHE)
).all()
ids.update(r[0] for r in rows)
for creator in aff.top("creators", _AFFINITY_CREATORS):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(Clip.id).where(Clip.creator == creator, Clip.id > start).order_by(Clip.id).limit(_PER_CREATOR)
).all()
if len(rows) < _PER_CREATOR:
rows += session.execute(
select(Clip.id).where(Clip.creator == creator).order_by(Clip.id).limit(_PER_CREATOR - len(rows))
).all()
ids.update(r[0] for r in rows)
return ids
# ---- feed -------------------------------------------------------------------------------
@router.get("/feed", response_model=FeedPageOut)
def clip_feed(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
seed: str | None = Query(default=None, max_length=16),
cursor: str | None = Query(default=None, max_length=200),
limit: int = Query(default=10, ge=1, le=30),
) -> FeedPageOut:
t0 = time.monotonic()
decoded = cf.decode_cursor(cursor) if cursor else None
if decoded:
seed, pk, page = decoded
else:
seed = (seed or cf.new_seed())[:16]
pk, page = cf.seed_start_uuid(seed), 0
rng = cf.page_rng(seed, page)
_row, aff = _load_affinity(session, device_id)
personalized = not aff.is_empty()
bl = _blacklist(session, device_id)
clauses = _visible_clauses(device_id, bl)
n_aff, n_explore = cf.split_counts(limit, personalized)
picked_aff: list[cf.Candidate] = []
if n_aff:
cand_ids = _affinity_candidate_ids(session, aff, rng)
picked_aff = cf.pick_affinity(_candidates(session, list(cand_ids), clauses), aff, n_aff)
taken = {c.id for c in picked_aff}
# Explore: okno po PK; gdy w oknie brak widocznych, przesuwamy sie dalej (max 3 okna).
picked_explore: list[cf.Candidate] = []
wrapped = False
next_pk = pk
want = n_explore + max(0, n_aff - len(picked_aff)) # braki z afinicznosci dopelnia explore
for _ in range(3):
ids, w = _explore_window(session, next_pk)
wrapped = wrapped or w
if not ids:
break
next_pk = ids[-1] if not w else _UUID_ZERO
pool = [c for c in _candidates(session, ids, clauses) if c.id not in taken]
picked_explore += cf.pick_explore(pool, want - len(picked_explore), rng)
if len(picked_explore) >= want or len(ids) < _EXPLORE_WINDOW:
break
page_items = cf.compose_page(picked_aff, picked_explore, rng)
clip_ids = [c.id for c, _ in page_items]
if clip_ids:
session.execute(
pg_insert(ClipSeen)
.values([{"device_id": device_id, "clip_id": cid, "outcome": "served"} for cid in clip_ids])
.on_conflict_do_nothing()
)
rows = {r.id: r for r in session.execute(select(Clip).where(Clip.id.in_(clip_ids))).scalars()} if clip_ids else {}
tags = _tags_for(session, clip_ids)
favs = _favorites_for(session, device_id, clip_ids)
served_total = session.execute(
select(func.count()).select_from(ClipSeen).where(ClipSeen.device_id == device_id)
).scalar_one()
session.commit()
items = [
FeedItemOut(
clip=_clip_out(rows[c.id], tags.get(c.id, []), c.id in favs),
slot=slot,
is_fresh=cf.is_fresh(rows[c.id].published_at),
)
for c, slot in page_items
if c.id in rows
]
log.info(
"clips/feed: device=%s page=%d served=%d aff=%d explore=%d wrapped=%s ms=%d",
device_id[:8], page, len(items), len(picked_aff), len(picked_explore), wrapped,
int((time.monotonic() - t0) * 1000),
)
return FeedPageOut(
items=items,
next_cursor=cf.encode_cursor(seed, next_pk, page + 1),
seed=seed,
wrapped=wrapped,
served_total=int(served_total),
personalized=personalized,
)
# ---- zdarzenia --------------------------------------------------------------------------
@router.post("/events", status_code=204)
def clip_events(
body: FeedEventsIn,
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
) -> Response:
events = [e for e in body.events if e.kind in cf.ALLOWED_KINDS]
if not events:
return Response(status_code=204)
clip_ids = list({e.clip_id for e in events})
clips = {r.id: r for r in session.execute(select(Clip).where(Clip.id.in_(clip_ids))).scalars()}
tags = _tags_for(session, clip_ids)
seen = {
r.clip_id: r
for r in session.execute(
select(ClipSeen).where(ClipSeen.device_id == device_id, ClipSeen.clip_id.in_(clip_ids))
).scalars()
}
row, aff = _load_affinity(session, device_id)
changed = 0
for e in events:
session.add(
ClipFeedEvent(
device_id=device_id,
session_id=body.session_id,
clip_id=e.clip_id,
kind=e.kind,
dwell_ms=e.dwell_ms,
reason=e.reason,
slot=e.slot,
position=e.position,
)
)
clip = clips.get(e.clip_id)
if clip is None:
continue
# clip_seen: podnies wynik, nigdy nie cofaj.
outcome = e.kind if e.kind in cf.OUTCOME_RANK else None
if outcome:
s = seen.get(e.clip_id)
if s is None:
s = ClipSeen(device_id=device_id, clip_id=e.clip_id, outcome=outcome)
session.add(s)
seen[e.clip_id] = s
elif cf.OUTCOME_RANK[outcome] > cf.OUTCOME_RANK.get(s.outcome, 0):
s.outcome = outcome
if e.kind == "like":
session.execute(
pg_insert(ClipFavorite).values(device_id=device_id, clip_id=e.clip_id).on_conflict_do_nothing()
)
elif e.kind == "unlike":
session.execute(
text("DELETE FROM clip_favorites WHERE device_id = :d AND clip_id = :c"),
{"d": device_id, "c": e.clip_id},
)
if cf.apply_event(
aff,
kind=e.kind,
dwell_ms=e.dwell_ms,
reason=e.reason,
tags=tags.get(e.clip_id, []),
niches=list(clip.niches or []),
creator=clip.creator,
):
changed += 1
if changed:
_save_affinity(session, device_id, row, aff, changed)
session.commit()
return Response(status_code=204)
# ---- onboarding, afinicznosc ------------------------------------------------------------
_OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}
@router.get("/onboarding-options", response_model=OnboardingOptionsOut)
def onboarding_options(session: Session = Depends(get_session)) -> OnboardingOptionsOut:
"""Najpopularniejsze tagi i nisze do wyboru „3 tapów" przy pierwszym wejsciu.
Liczone z bazy, cache 10 min w procesie."""
hit = _OPTIONS_CACHE.get("v1")
if hit and time.monotonic() - hit[0] < 600:
return hit[1]
tags = [
OnboardingOptionOut(value=t, count=n)
for t, n in session.execute(
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(40)
)
]
niches = [
OnboardingOptionOut(value=v, count=n)
for v, n in session.execute(
text("SELECT n, count(*) FROM clips, unnest(niches) n WHERE dead_at IS NULL GROUP BY n ORDER BY 2 DESC LIMIT 30")
)
]
out = OnboardingOptionsOut(tags=tags, niches=niches)
_OPTIONS_CACHE["v1"] = (time.monotonic(), out)
return out
@router.post("/onboarding", response_model=AffinityOut)
def onboarding(
body: OnboardingIn,
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
) -> AffinityOut:
row, aff = _load_affinity(session, device_id)
for t in body.tags:
t = t.strip().lower()[:64]
if t:
aff.tags[t] = round(aff.tags.get(t, 0.0) + 5.0, 3)
for n in body.niches:
n = n.strip()[:64]
if n:
aff.niches[n] = round(aff.niches.get(n, 0.0) + 5.0, 3)
_save_affinity(session, device_id, row, aff, 0)
session.commit()
row2 = session.get(ClipAffinity, device_id)
return AffinityOut(tags=aff.tags, niches=aff.niches, creators=aff.creators, events_seen=row2.events_seen if row2 else 0)
@router.get("/affinity", response_model=AffinityOut)
def get_affinity(session: Session = Depends(get_session), device_id: str = Depends(get_device_id)) -> AffinityOut:
row, aff = _load_affinity(session, device_id)
return AffinityOut(tags=aff.tags, niches=aff.niches, creators=aff.creators, events_seen=row.events_seen if row else 0)
@router.delete("/affinity", status_code=204)
def reset_affinity(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
seen: bool = Query(default=False, description="True = zapomnij tez widziane klipy (feed od zera)"),
) -> Response:
"""„Resetuj moj feed": kasuje wagi; z `seen=true` takze pamiec widzianych."""
session.execute(text("DELETE FROM clip_affinity WHERE device_id = :d"), {"d": device_id})
if seen:
session.execute(text("DELETE FROM clip_seen WHERE device_id = :d"), {"d": device_id})
session.commit()
return Response(status_code=204)
# ---- ulubione, blacklista ----------------------------------------------------------------
@router.get("/favorites", response_model=ClipListOut)
def clip_favorites(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ClipListOut:
rows = session.execute(
select(Clip)
.join(ClipFavorite, ClipFavorite.clip_id == Clip.id)
.where(ClipFavorite.device_id == device_id)
.order_by(ClipFavorite.created_at.desc())
.offset(offset)
.limit(limit + 1)
).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
tags = _tags_for(session, [r.id for r in rows])
return ClipListOut(
items=[_clip_out(r, tags.get(r.id, []), True) for r in rows], limit=limit, offset=offset, has_more=has_more
)
@router.get("/blacklist", response_model=list[BlacklistItemOut])
def clip_blacklist(session: Session = Depends(get_session), device_id: str = Depends(get_device_id)) -> list[BlacklistItemOut]:
return [
BlacklistItemOut(kind=k, value=v)
for k, v in session.execute(
select(ClipBlacklist.kind, ClipBlacklist.value).where(ClipBlacklist.device_id == device_id).order_by(ClipBlacklist.kind, ClipBlacklist.value)
)
]
@router.post("/blacklist/{kind}/{value}", status_code=204)
def clip_blacklist_add(
kind: str, value: str, session: Session = Depends(get_session), device_id: str = Depends(get_device_id)
) -> Response:
if kind not in _BLACKLIST_KINDS or not value.strip():
raise HTTPException(status_code=400, detail="kind must be tag|niche|creator")
session.execute(
pg_insert(ClipBlacklist).values(device_id=device_id, kind=kind, value=value.strip()[:128]).on_conflict_do_nothing()
)
session.commit()
return Response(status_code=204)
@router.delete("/blacklist/{kind}/{value}", status_code=204)
def clip_blacklist_remove(
kind: str, value: str, session: Session = Depends(get_session), device_id: str = Depends(get_device_id)
) -> Response:
session.execute(
text("DELETE FROM clip_blacklist WHERE device_id = :d AND kind = :k AND value = :v"),
{"d": device_id, "k": kind, "v": value.strip()[:128]},
)
session.commit()
return Response(status_code=204)
# ---- listing i statystyki (pilot) --------------------------------------------------------
@router.get("", response_model=ClipListOut)
def list_clips(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
tag: str | None = Query(default=None, max_length=64),
source: str | None = Query(default=None, max_length=16),
creator: str | None = Query(default=None, max_length=128),
@ -101,33 +628,12 @@ def list_clips(
).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
tags = _tags_for(session, [r.id for r in rows])
items = [
ClipOut(
id=r.id,
source=r.source,
source_id=r.source_id,
title=r.title,
creator=r.creator,
creator_verified=r.creator_verified,
duration_sec=r.duration_sec,
width=r.width,
height=r.height,
has_audio=r.has_audio,
content_type=r.content_type,
niches=list(r.niches or []),
tags=tags.get(r.id, []),
hd_url=r.hd_url,
sd_url=r.sd_url,
poster_url=r.poster_url,
thumb_url=r.thumb_url,
likes=r.likes,
views=r.views,
published_at=r.published_at,
ids = [r.id for r in rows]
tags = _tags_for(session, ids)
favs = _favorites_for(session, device_id, ids)
return ClipListOut(
items=[_clip_out(r, tags.get(r.id, []), r.id in favs) for r in rows], limit=limit, offset=offset, has_more=has_more
)
for r in rows
]
return ClipListOut(items=items, limit=limit, offset=offset, has_more=has_more)
@router.get("/stats", response_model=ClipStatsOut)
@ -142,17 +648,9 @@ def clip_stats(session: Session = Depends(get_session)) -> ClipStatsOut:
func.max(Clip.published_at),
)
).one()
by_source = {
s: n for s, n in session.execute(select(Clip.source, func.count()).group_by(Clip.source))
}
by_source = {s: n for s, n in session.execute(select(Clip.source, func.count()).group_by(Clip.source))}
tags = session.execute(select(func.count(func.distinct(ClipTag.tag)))).scalar_one()
return ClipStatsOut(
total=row[0],
alive=row[1],
dead=row[2],
with_audio=row[3],
verified=row[4],
by_source=by_source,
newest_published_at=row[5],
tags=tags,
total=row[0], alive=row[1], dead=row[2], with_audio=row[3], verified=row[4],
by_source=by_source, newest_published_at=row[5], tags=tags,
)

View file

@ -33,6 +33,11 @@ _TABLES: list[tuple[str, str]] = [
("blacklisted_performers", "performer_id"),
("blacklisted_studios", "studio_id"),
("blacklisted_tags", "tag_id"),
# Feed Swipe: widziane klipy i ulubione klipy jadą z użytkownikiem na nowy telefon.
# Afiniczność (clip_affinity) celowo NIE: ma jeden wiersz na urządzenie bez kolumny
# encji, a i tak odbuduje się z pierwszych zdarzeń.
("clip_seen", "clip_id"),
("clip_favorites", "clip_id"),
]
@ -48,7 +53,10 @@ class ImportOut(BaseModel):
# Tabele do przeniesienia przy transferze między urządzeniami + kolumna-encji do dedupu.
# saved_searches ma własne `id` PK (nie (device_id,encja)), więc dedup po `query`.
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [("saved_searches", "query")]
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [
("saved_searches", "query"),
("clip_blacklist", "value"),
]
@router.post("/import/{source_device}", response_model=ImportOut)

243
app/clips_feed.py Normal file
View file

@ -0,0 +1,243 @@
"""Logika feedu Swipe bez bazy: kursor, wagi afiniczności, punktacja, składanie strony.
Wyniesione z `app/api/clips.py`, żeby dało się testować bez Postgresa (`tests/test_clips_feed.py`).
**Kursor** = base64 JSON `{"s": seed, "pk": <uuid>, "p": <nr strony>}`. Nie offset: pula
„explore" idzie oknem po kluczu głównym od `pk` (ten sam trik co `sort=random` w
`list_scenes`), pula afiniczności losuje punkty startu z `Random(seed, p)`. Dzięki temu
dwa telefony z tym samym seedem dostają inne strony tylko przez różne `clip_seen`,
a pull-to-refresh to po prostu nowy seed.
**Wagi**: trzy słowniki (tag, nisza, twórca) → liczba. Zdarzenia dodają delty
(`EVENT_DELTAS`), powód „nie interesuje" dokłada karę na wskazany cel, nocny decay ×0,97
zapomina stare gusta, cap trzyma słowniki małe (60/40/40). Wszystko celowo proste i
przejrzyste: da się wytłumaczyć użytkownikowi, dlaczego coś widzi, i skasować jednym
wywołaniem.
**Punktacja kandydata** = Σ wag tagów + 2·waga niszy + 3·waga twórcy + 0,3·ln(1+lajki)
+ 0,5 gdy klip z ostatnich 7 dni. Strona: 80 % z afiniczności (max 2 klipy jednego twórcy),
20 % explore, przemieszane deterministycznie z seeda.
"""
from __future__ import annotations
import base64
import hashlib
import json
import math
import random
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
FRESH_DAYS = 7
EXPLORE_SHARE = 0.2
MAX_PER_CREATOR = 2
CAP_TAGS, CAP_NICHES, CAP_CREATORS = 60, 40, 40
DECAY_FACTOR = 0.97
DECAY_FLOOR = 0.2
#: Poniżej tego dwell „impression" nic nie znaczy, powyżej `DWELL_POSITIVE_MS` to plus.
SKIP_MS = 2000
DWELL_POSITIVE_MS = 10000
#: kind → (delta tag, delta nisza, delta twórca)
EVENT_DELTAS: dict[str, tuple[float, float, float]] = {
"complete": (3.0, 3.0, 2.0),
"like": (5.0, 5.0, 4.0),
"unlike": (-5.0, -5.0, -4.0),
"dwell": (1.0, 1.0, 0.5),
"skip": (-1.0, -1.0, -0.5),
"not_interested": (-4.0, -4.0, -3.0),
}
REASON_PENALTY = -8.0
ALLOWED_KINDS = frozenset(
{"impression", "skip", "complete", "like", "unlike", "not_interested", "autoswipe", "open"}
)
#: Kolejność „siły" wyniku w `clip_seen`: nie cofamy do słabszego.
OUTCOME_RANK = {"served": 0, "skip": 1, "complete": 2, "like": 3, "not_interested": 4}
# ---- kursor -----------------------------------------------------------------------------
def new_seed() -> str:
return uuid.uuid4().hex[:12]
def seed_start_uuid(seed: str, salt: str = "") -> uuid.UUID:
return uuid.UUID(hashlib.md5(f"{seed}:{salt}".encode()).hexdigest())
def encode_cursor(seed: str, pk: uuid.UUID, page: int) -> str:
raw = json.dumps({"s": seed, "pk": str(pk), "p": page}, separators=(",", ":"))
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def decode_cursor(cursor: str) -> tuple[str, uuid.UUID, int] | None:
"""None gdy kursor jest zepsuty (klient zaczyna wtedy od nowa, bez 400)."""
try:
pad = "=" * (-len(cursor) % 4)
d = json.loads(base64.urlsafe_b64decode(cursor + pad).decode())
seed = str(d["s"])[:16]
pk = uuid.UUID(str(d["pk"]))
page = int(d["p"])
if not seed or page < 0 or page > 100000:
return None
return seed, pk, page
except Exception:
return None
def page_rng(seed: str, page: int) -> random.Random:
return random.Random(f"{seed}:{page}")
# ---- wagi -------------------------------------------------------------------------------
@dataclass
class Affinity:
tags: dict[str, float] = field(default_factory=dict)
niches: dict[str, float] = field(default_factory=dict)
creators: dict[str, float] = field(default_factory=dict)
def is_empty(self) -> bool:
return not (
any(w > 0 for w in self.tags.values())
or any(w > 0 for w in self.niches.values())
or any(w > 0 for w in self.creators.values())
)
def top(self, which: str, n: int) -> list[str]:
d = getattr(self, which)
return [k for k, w in sorted(d.items(), key=lambda kv: -kv[1]) if w > 0][:n]
def _bump(d: dict[str, float], key: str | None, delta: float) -> None:
if not key or not delta:
return
d[key] = round(d.get(key, 0.0) + delta, 3)
def cap_weights(d: dict[str, float], n: int) -> dict[str, float]:
"""Zostaw `n` pozycji o największej wartości bezwzględnej (kary też są informacją)."""
if len(d) <= n:
return d
keep = sorted(d.items(), key=lambda kv: -abs(kv[1]))[:n]
return dict(keep)
def decay_weights(d: dict[str, float], factor: float = DECAY_FACTOR, floor: float = DECAY_FLOOR) -> dict[str, float]:
out = {k: round(v * factor, 3) for k, v in d.items()}
return {k: v for k, v in out.items() if abs(v) >= floor}
def event_kind_for_weights(kind: str, dwell_ms: int | None) -> str | None:
"""Zdarzenie z apki → klucz w `EVENT_DELTAS`. `impression` liczy się tylko przy długim
dwell, `skip` tylko przy krótkim; `autoswipe`/`open` nie ruszają wag."""
if kind == "impression":
return "dwell" if (dwell_ms or 0) >= DWELL_POSITIVE_MS else None
if kind == "skip":
return "skip" if (dwell_ms or 0) < SKIP_MS else None
return kind if kind in EVENT_DELTAS else None
def apply_event(
aff: Affinity,
*,
kind: str,
dwell_ms: int | None,
reason: str | None,
tags: list[str],
niches: list[str],
creator: str | None,
) -> bool:
"""Nakłada delty zdarzenia na wagi. True gdy coś się zmieniło."""
wkind = event_kind_for_weights(kind, dwell_ms)
changed = False
if wkind:
dt, dn, dc = EVENT_DELTAS[wkind]
for t in tags:
_bump(aff.tags, t, dt)
for n in niches:
_bump(aff.niches, n, dn)
_bump(aff.creators, creator, dc)
changed = True
if kind == "not_interested" and reason and ":" in reason:
target_kind, _, target = reason.partition(":")
target = target.strip()
if target_kind == "tag":
_bump(aff.tags, target, REASON_PENALTY)
elif target_kind == "niche":
_bump(aff.niches, target, REASON_PENALTY)
elif target_kind == "creator":
_bump(aff.creators, target, REASON_PENALTY)
changed = True
if changed:
aff.tags = cap_weights(aff.tags, CAP_TAGS)
aff.niches = cap_weights(aff.niches, CAP_NICHES)
aff.creators = cap_weights(aff.creators, CAP_CREATORS)
return changed
# ---- punktacja i składanie strony -----------------------------------------------------
@dataclass
class Candidate:
id: uuid.UUID
creator: str | None
likes: int
published_at: datetime | None
tags: list[str]
niches: list[str]
def score_candidate(c: Candidate, aff: Affinity, now: datetime | None = None) -> float:
now = now or datetime.now(UTC)
s = sum(aff.tags.get(t, 0.0) for t in c.tags)
s += 2.0 * sum(aff.niches.get(n, 0.0) for n in c.niches)
s += 3.0 * aff.creators.get(c.creator or "", 0.0)
s += 0.3 * math.log1p(max(c.likes, 0))
if c.published_at and c.published_at >= now - timedelta(days=FRESH_DAYS):
s += 0.5
return s
def pick_affinity(cands: list[Candidate], aff: Affinity, n: int, *, max_per_creator: int = MAX_PER_CREATOR) -> list[Candidate]:
"""Najlepsze `n` po punktach, max `max_per_creator` klipów jednego twórcy na stronę."""
ranked = sorted(cands, key=lambda c: -score_candidate(c, aff))
out: list[Candidate] = []
per: dict[str, int] = {}
for c in ranked:
key = c.creator or ""
if per.get(key, 0) >= max_per_creator:
continue
per[key] = per.get(key, 0) + 1
out.append(c)
if len(out) >= n:
break
return out
def pick_explore(cands: list[Candidate], n: int, rng: random.Random) -> list[Candidate]:
"""Losowanie ważone popularnością: ln(1+lajki)+1, żeby zero lajków też miało szansę."""
pool = list(cands)
out: list[Candidate] = []
while pool and len(out) < n:
weights = [math.log1p(max(c.likes, 0)) + 1.0 for c in pool]
pick = rng.choices(range(len(pool)), weights=weights, k=1)[0]
out.append(pool.pop(pick))
return out
def split_counts(limit: int, has_affinity: bool) -> tuple[int, int]:
"""(z afiniczności, explore). Bez wag wszystko idzie z explore."""
if not has_affinity:
return 0, limit
n_explore = max(1, round(limit * EXPLORE_SHARE))
return limit - n_explore, n_explore
def compose_page(aff_items: list[Candidate], explore_items: list[Candidate], rng: random.Random) -> list[tuple[Candidate, str]]:
page = [(c, "affinity") for c in aff_items] + [(c, "explore") for c in explore_items]
rng.shuffle(page)
return page
def is_fresh(published_at: datetime | None, now: datetime | None = None) -> bool:
now = now or datetime.now(UTC)
return bool(published_at and published_at >= now - timedelta(days=FRESH_DAYS))

View file

@ -230,6 +230,13 @@ class Settings(BaseSettings):
# niszach ręcznie: `python -m scripts.clips_backfill_redgifs`.
sched_clips_hours: int = Field(default=1, validation_alias="GOON_SCHED_CLIPS_HOURS")
clips_latest_pages: int = Field(default=5, validation_alias="GOON_CLIPS_LATEST_PAGES")
# Konserwacja feedu Swipe raz na dobę: decay wag afiniczności (×0,97), retencja
# zdarzeń (90 dni), reaper martwych plików (HEAD na 2000 najdawniej sprawdzanych).
sched_clips_maintenance_hours: int = Field(
default=24, validation_alias="GOON_SCHED_CLIPS_MAINTENANCE_HOURS"
)
# Feed podaje tylko klipy do tej długości (RedGifs p90 = 60 s; dłuższe to nie „swipe").
clips_feed_max_duration_sec: int = Field(default=120, validation_alias="GOON_CLIPS_FEED_MAX_DURATION_SEC")
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB),
# używany do ingestu HTML (scrape) tubów które blokują VPS IP twardym Cloudflare

View file

@ -2,6 +2,7 @@ from app.models.base import Base
from app.models.blacklist import BlacklistedPerformer, BlacklistedStudio, BlacklistedTag
from app.models.bug_report import BugReport
from app.models.clip import Clip, ClipTag
from app.models.clip_feed import ClipAffinity, ClipBlacklist, ClipFavorite, ClipFeedEvent, ClipSeen
from app.models.external_record import ExternalRecord
from app.models.favorite_movie import FavoriteMovie
from app.models.favorite_performer import FavoritePerformer
@ -45,6 +46,11 @@ __all__ = [
"BlacklistedTag",
"BugReport",
"Clip",
"ClipAffinity",
"ClipBlacklist",
"ClipFavorite",
"ClipFeedEvent",
"ClipSeen",
"ClipTag",
"ExternalRecord",
"FavoriteMovie",

104
app/models/clip_feed.py Normal file
View file

@ -0,0 +1,104 @@
"""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
)

View file

@ -169,3 +169,92 @@ def run_redgifs_niches(
client.close()
log.info("clips/redgifs niches: %s", total)
return total
# ---- konserwacja (raz na dobe) ---------------------------------------------------------
_EVENTS_RETENTION_DAYS = 90
_DEAD_CODES = {403, 404, 410}
_REAP_UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0 Safari/537.36"}
def reap_dead(*, limit: int = 2000, apply: bool = True) -> dict[str, int]:
"""Reaper martwych klipow: tworcy kasuja klipy, RedGifs oddaje wtedy 404/410 na pliku.
HEAD na `hd_url` (albo `sd_url`) dla najdawniej sprawdzanych zywych klipow
(`updated_at`); 403/404/410 → `dead_at`. Bledy sieci NIE oznaczaja smierci (patrz
notatka o dwoch kontrolach przy przemiale zrodel). Klip, ktory zrodlo znow poda
w listingu, upsert odzywia. Sprawdzone i zywe dostaja swiezy `updated_at`, zeby
kolejny przebieg bral inne.
"""
import time as _time
import httpx
from sqlalchemy import select
checked = dead = errors = 0
dead_ids: list[tuple] = []
with session_scope() as session:
rows = session.execute(
select(Clip.id, Clip.hd_url, Clip.sd_url)
.where(Clip.dead_at.is_(None))
.order_by(Clip.updated_at.asc())
.limit(limit)
).all()
with httpx.Client(headers=_REAP_UA, timeout=20, follow_redirects=True) as http:
for cid, hd, sd in rows:
url = hd or sd
if not url:
continue
checked += 1
try:
r = http.head(url)
except httpx.HTTPError:
errors += 1
continue
if r.status_code in _DEAD_CODES:
dead += 1
dead_ids.append((cid, f"http {r.status_code}"))
_time.sleep(0.2)
if apply and rows:
dead_set = {d[0] for d in dead_ids}
with session_scope() as session:
for cid, reason in dead_ids:
session.execute(
text("UPDATE clips SET dead_at = now(), dead_reason = :r WHERE id = :id AND dead_at IS NULL"),
{"r": reason, "id": cid},
)
alive = [cid for cid, _hd, _sd in rows if cid not in dead_set]
if alive:
session.execute(text("UPDATE clips SET updated_at = now() WHERE id = ANY(:ids)"), {"ids": alive})
out = {"checked": checked, "dead": dead, "errors": errors, "applied": int(apply)}
log.info("clips reaper: %s", out)
return out
def decay_affinity() -> int:
"""Nocne zapominanie: wszystkie wagi ×0,97, |w| < 0,2 znika. Zwraca liczbe urzadzen."""
from app.clips_feed import decay_weights
from app.models.clip_feed import ClipAffinity
n = 0
with session_scope() as session:
for row in session.query(ClipAffinity).all():
row.tag_weights = decay_weights(dict(row.tag_weights or {}))
row.niche_weights = decay_weights(dict(row.niche_weights or {}))
row.creator_weights = decay_weights(dict(row.creator_weights or {}))
n += 1
return n
def run_clips_maintenance() -> dict[str, int]:
out: dict[str, int] = {}
out["affinity_rows"] = decay_affinity()
with session_scope() as session:
res = session.execute(
text("DELETE FROM clip_feed_events WHERE created_at < now() - make_interval(days => :d)"),
{"d": _EVENTS_RETENTION_DAYS},
)
out["events_pruned"] = int(res.rowcount or 0)
out.update({f"reap_{k}": v for k, v in reap_dead(limit=2000, apply=True).items()})
log.info("clips maintenance: %s", out)
return out

View file

@ -487,6 +487,16 @@ def _job_clips_latest(pages: int) -> None:
log.exception("[scheduler] clips-latest failed")
def _job_clips_maintenance() -> None:
"""Feed Swipe: decay afiniczności, retencja zdarzeń, reaper martwych plików."""
from app.scheduler.clips_ingest import run_clips_maintenance
try:
_run_with_timeout(run_clips_maintenance, label="clips-maintenance")
except Exception:
log.exception("[scheduler] clips-maintenance failed")
def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
"""Buduje scheduler na podstawie cfg dictu.
@ -574,6 +584,17 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
)
log.info("scheduler: clips-latest every %dh (pages=%d)", cfg["clips_hours"], clips_pages)
if cfg.get("clips_maintenance_hours"):
sched.add_job(
_job_clips_maintenance,
IntervalTrigger(hours=cfg["clips_maintenance_hours"], start_date=INTERVAL_ANCHOR),
id="clips_maintenance",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: clips-maintenance every %dh", cfg["clips_maintenance_hours"])
if cfg.get("bulk_dedup_hours"):
sched.add_job(
_job_bulk_dedup_performers,

View file

@ -252,6 +252,7 @@ def run_forever() -> int:
# Klipy do feedu Swipe (RedGifs „najnowsze"), co godzinę, N stron po 100.
"clips_hours": getattr(settings, "sched_clips_hours", 1) or None,
"clips_latest_pages": getattr(settings, "clips_latest_pages", 5),
"clips_maintenance_hours": getattr(settings, "sched_clips_maintenance_hours", 24) or None,
}
sched = build_scheduler(cfg)
log.info("worker scheduled mode starting (jobs=%d)", len(sched.get_jobs()))

View file

@ -1,73 +1,15 @@
"""Reaper martwych klipów: twórcy kasują klipy, RedGifs oddaje wtedy 404/410 na pliku.
"""Reaper martwych klipów, ręczne uruchomienie. Logika w `app.scheduler.clips_ingest.reap_dead`
(ta sama, którą raz na dobę odpala job `clips-maintenance`).
Sprawdza HEAD `hd_url` (albo `sd_url`) dla próbki żywych klipów: najpierw te najdawniej
sprawdzane (`updated_at`), bo ingest `latest` i tak odświeża świeże. 404/410/403 →
`dead_at` + `dead_reason`; błędy sieci nie oznaczają śmierci (patrz notatka o dwóch
kontrolach przy przemiale źródeł). Klip, który źródło znów poda w listingu, upsert
odżywia (zdejmuje `dead_at`).
Uruchomienie (kontener worker):
python -m scripts.clips_reap_dead --limit 500 # dry-run
python -m scripts.clips_reap_dead --limit 500 --yes # zapisz
python -m scripts.clips_reap_dead --limit 500 --yes # zapisz dead_at
"""
from __future__ import annotations
import argparse
import logging
import time
import httpx
from sqlalchemy import select, text
from app.db import session_scope
from app.models.clip import Clip
log = logging.getLogger(__name__)
_UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0 Safari/537.36"}
_DEAD_CODES = {403, 404, 410}
def run(*, limit: int, apply: bool) -> dict[str, int]:
checked = dead = errors = 0
dead_ids: list = []
with session_scope() as session:
rows = session.execute(
select(Clip.id, Clip.hd_url, Clip.sd_url)
.where(Clip.dead_at.is_(None))
.order_by(Clip.updated_at.asc())
.limit(limit)
).all()
with httpx.Client(headers=_UA, timeout=20, follow_redirects=True) as http:
for cid, hd, sd in rows:
url = hd or sd
if not url:
continue
checked += 1
try:
r = http.head(url)
except httpx.HTTPError:
errors += 1
continue
if r.status_code in _DEAD_CODES:
dead += 1
dead_ids.append((cid, f"http {r.status_code}"))
time.sleep(0.2)
if apply:
with session_scope() as session:
for cid, reason in dead_ids:
session.execute(
text("UPDATE clips SET dead_at = now(), dead_reason = :r WHERE id = :id AND dead_at IS NULL"),
{"r": reason, "id": cid},
)
# Sprawdzone i zywe: odswiez updated_at, zeby kolejny run bral inne.
alive = [cid for cid, _hd, _sd in rows if cid not in {d[0] for d in dead_ids}]
if alive:
session.execute(
text("UPDATE clips SET updated_at = now() WHERE id = ANY(:ids)"), {"ids": alive}
)
out = {"checked": checked, "dead": dead, "errors": errors, "applied": int(apply)}
print(out)
return out
from app.scheduler.clips_ingest import reap_dead
def main() -> None:
@ -76,7 +18,7 @@ def main() -> None:
ap.add_argument("--limit", type=int, default=500)
ap.add_argument("--yes", action="store_true", help="zapisz dead_at (bez tego dry-run)")
a = ap.parse_args()
run(limit=a.limit, apply=a.yes)
print(reap_dead(limit=a.limit, apply=a.yes))
if __name__ == "__main__":

97
tests/test_clips_feed.py Normal file
View file

@ -0,0 +1,97 @@
import random
import uuid
from datetime import UTC, datetime, timedelta
from app.clips_feed import (
Affinity,
Candidate,
apply_event,
cap_weights,
compose_page,
decay_weights,
decode_cursor,
encode_cursor,
event_kind_for_weights,
pick_affinity,
pick_explore,
score_candidate,
split_counts,
)
def test_cursor_roundtrip_and_garbage():
pk = uuid.uuid4()
c = encode_cursor("abc123", pk, 7)
assert decode_cursor(c) == ("abc123", pk, 7)
assert decode_cursor("not-a-cursor") is None
assert decode_cursor("") is None
def test_event_kind_mapping():
assert event_kind_for_weights("impression", 12000) == "dwell"
assert event_kind_for_weights("impression", 3000) is None
assert event_kind_for_weights("skip", 500) == "skip"
assert event_kind_for_weights("skip", 5000) is None
assert event_kind_for_weights("like", None) == "like"
assert event_kind_for_weights("autoswipe", None) is None
def test_apply_event_like_and_not_interested_reason():
aff = Affinity()
changed = apply_event(aff, kind="like", dwell_ms=None, reason=None, tags=["big-tits", "amateur"], niches=["just-boobs"], creator="kitsune")
assert changed
assert aff.tags["big-tits"] == 5.0 and aff.niches["just-boobs"] == 5.0 and aff.creators["kitsune"] == 4.0
apply_event(aff, kind="not_interested", dwell_ms=None, reason="tag:amateur", tags=["amateur"], niches=[], creator=None)
# -4 za zdarzenie i -8 za wskazany tag
assert aff.tags["amateur"] == 5.0 - 4.0 - 8.0
assert not aff.is_empty()
assert aff.top("tags", 1) == ["big-tits"]
def test_cap_and_decay():
d = {f"t{i}": float(i) for i in range(100)}
capped = cap_weights(d, 10)
assert len(capped) == 10 and "t99" in capped and "t0" not in capped
decayed = decay_weights({"a": 1.0, "b": 0.2}, factor=0.5, floor=0.2)
assert decayed == {"a": 0.5} # b spada pod prog i znika
def _cand(creator, likes=0, tags=(), niches=(), days_old=30):
return Candidate(
id=uuid.uuid4(), creator=creator, likes=likes,
published_at=datetime.now(UTC) - timedelta(days=days_old), tags=list(tags), niches=list(niches),
)
def test_pick_affinity_respects_creator_cap_and_order():
aff = Affinity(tags={"x": 10.0})
cands = [_cand("a", tags=["x"]) for _ in range(5)] + [_cand("b", tags=["x"], likes=1)]
picked = pick_affinity(cands, aff, 4, max_per_creator=2)
creators = [c.creator for c in picked]
assert creators.count("a") == 2 and "b" in creators and len(picked) == 3
def test_score_prefers_fresh_and_liked():
aff = Affinity()
old = _cand("a", likes=0, days_old=100)
fresh = _cand("a", likes=0, days_old=1)
liked = _cand("a", likes=1000, days_old=100)
assert score_candidate(fresh, aff) > score_candidate(old, aff)
assert score_candidate(liked, aff) > score_candidate(old, aff)
def test_split_and_compose_deterministic():
assert split_counts(10, False) == (0, 10)
assert split_counts(10, True) == (8, 2)
a = [_cand("a") for _ in range(8)]
e = [_cand("e") for _ in range(2)]
p1 = compose_page(list(a), list(e), random.Random("s:1"))
p2 = compose_page(list(a), list(e), random.Random("s:1"))
assert [c.id for c, _ in p1] == [c.id for c, _ in p2]
assert sum(1 for _, slot in p1 if slot == "explore") == 2
def test_pick_explore_weighted_but_complete():
cands = [_cand("a", likes=0), _cand("b", likes=10**6)]
out = pick_explore(cands, 2, random.Random(1))
assert {c.creator for c in out} == {"a", "b"}