goon/tests/test_clips_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

97 lines
3.5 KiB
Python

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"}