feat(social): add listening activity, shared rooms and modern chat
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s

This commit is contained in:
mrleo1nid committed 2026-10-23 10:45:40 +03:00
1 parent 0a05bfe2cd
commit 11a16cd023
59 files changed
+5022 -542

No files matched your search

+10 -1
View File
@@ -313,6 +313,7 @@ def build_batch(
rng: np.random.Generator | None = None,
ranker: Ranker | None = None,
ctx: Context | None = None,
friend_ids: list[str] | None = None,
) -> list[Pick]:
"""Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия."""
rng = rng or np.random.default_rng()
@@ -363,6 +364,8 @@ def build_batch(
if seeds:
q = index.emb[seeds].mean(axis=0)
add(_top((index.emb @ q).astype(np.float64), allowed & index.has_emb, 100), "seed")
friends = [index.pos[t] for t in (friend_ids or []) if t in index.pos and allowed[index.pos[t]]][:10]
add(friends, "friends")
query = np.maximum(sim_session, sim_taste) if svec is not None or len(centroids) else None
if query is not None:
# «Новое» помечаем раньше audio_sim: неслышанный трек рядом со вкусом считается исследованием.
@@ -446,6 +449,8 @@ def build_batch(
pred = ranker.predict(x)
score = np.argsort(np.argsort(pred)) / max(len(pred) - 1, 1)
# Social influence is small, context-sensitive, and survives the learned ranker.
score = score + 0.06 * np.isin(cand, friends) * f
# 5. MMR + правила: 1 трек исполнителя на пачку, не больше 2 на последние 10; доля «нового».
picks: list[Pick] = []
chosen: list[int] = []
@@ -457,6 +462,8 @@ def build_batch(
best_j, best_v = -1, -np.inf
for j in order:
i = int(cand[j])
if sources[i] == "friends" and any(p.source == "friends" for p in picks):
continue
a = index.artist[i]
if any(index.artist[c] == a for c in chosen) or recent_artists.count(a) >= 2:
continue
@@ -471,7 +478,9 @@ def build_batch(
continue
if chosen:
chosen_artists = {index.artist[c] for c in chosen}
rest = [j for j in order if index.artist[int(cand[j])] in chosen_artists]
social_chosen = any(p.source == "friends" for p in picks)
rest = [j for j in order if index.artist[int(cand[j])] in chosen_artists
and not (sources[int(cand[j])] == "friends" and social_chosen)]
if not rest:
break
best_j = max(rest, key=lambda j: score[j])
@@ -38,6 +38,7 @@ class BatchRequest(BaseModel):
mood: str | None = None
seed: Seed | None = None
exclude_ids: list[str] = Field(default_factory=list, max_length=1000)
friend_track_ids: list[str] = Field(default_factory=list, max_length=30)
feedback: list[Feedback] = Field(default_factory=list, max_length=200)
n: int = Field(default=5, ge=1, le=20)
@@ -93,6 +94,7 @@ class WaveService:
n=req.n,
exclude=set(req.exclude_ids),
seed_ids=seeds,
friend_ids=req.friend_track_ids,
rng=np.random.default_rng(),
ranker=ranker,
ctx=Context(has_activity=bool(req.activity), has_mood=bool(req.mood)),
+17
View File
@@ -45,6 +45,23 @@ def profile(**kw: object) -> Profile:
return Profile(**base) # type: ignore[arg-type]
def test_friend_signal_is_bounded_and_respects_negative_feedback() -> None:
index = library()
blocked = {"t0-0", "t1-0"}
picks = build_batch(index, profile(disliked=blocked), Session(), [], n=5,
friend_ids=index.ids, rng=np.random.default_rng(8))
assert not blocked.intersection(p.id for p in picks)
assert sum(p.source == "friends" for p in picks) <= 1
assert len(picks) == 5
def test_empty_friend_signal_does_not_change_ranking() -> None:
index = library()
first = build_batch(index, profile(), Session(), [], rng=np.random.default_rng(17))
second = build_batch(index, profile(), Session(), [], friend_ids=[], rng=np.random.default_rng(17))
assert [p.id for p in first] == [p.id for p in second]
def cluster(track_id: str) -> int:
return int(track_id[1])