Compare commits

..
7 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 e250837de9 fix(search): reindex on a fresh Meilisearch
ci / db / db (push) Successful in 34s
release / release (версия и заметки) (push) Successful in 5s
ci / api / api (push) Successful in 19s
release / android (APK в релиз) (push) Successful in 3m17s
Deleting a missing <uid>_next index is accepted with 202 and fails inside the
task with index_not_found. That error was not treated as not-found, so the first
reindex aborted and search stayed unavailable until indexes existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:44:59 +03:00
mrleo1nidandClaude Opus 5.5 58bd14ef38 feat(recommender): LightGBM lambdarank ranker with impression features (M6)
ci / db / db (push) Failing after 1m15s
ci / api / api (push) Successful in 17s
ci / recommender / recommender (push) Successful in 32s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m41s
The wave logs each served candidate's feature vector to rec_impression_features.
Nightly training joins them with impression outcomes, validates on the last week
and activates the model only when it beats the v1 order and the active model.
The service loads the active ranker and uses its order in place of the v1 score.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:41:26 +03:00
mrleo1nidandClaude Opus 5.5 fb07f962eb feat: taste onboarding for new listeners (M6)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Failing after 1m29s
ci / ts / ts (packages + web) (push) Successful in 49s
ci / api / api (push) Successful in 1m25s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 48s
ci / recommender / recommender (push) Successful in 21s
- GET /onboarding offers the library's top artists (with album covers)
  and genres and says whether to show it (not done and < 10 plays);
  PUT /me/onboarding stores the choice or a skip (migration 00009).
- The recommender seeds the taste from it while the user has fewer than
  30 scored tracks: chosen artists' tracks +1, chosen genres +0.5.
- Web: onboarding replaces the wave on the home page until done or
  skipped; artist grid with covers, genre chips, at least 3 artists.
- e2e journey and the recommender DB test cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:35:43 +03:00
mrleo1nidandClaude Opus 5.5 ff0aafdb1e feat(recommender): Thompson-sampling bandit over candidate sources (M6)
ci / recommender / recommender (push) Failing after 4s
ci / db / db (push) Failing after 2m21s
Nightly Beta(α, β) per user and source from wave impressions (reward:
complete or like, ×0.99 daily decay) stored in user_profiles_rec.bandit;
the wave adds 0.2·(θ − 0.5) per candidate source, sources without data
stay neutral. The user's exploration share now follows how well `fresh`
does against the other sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:31:24 +03:00
mrleo1nidandClaude Opus 5.5 f7722e28ad ci(release): rebalance APK build memory so it does not thrash and get OOM-killed
release / android (APK в релиз) (push) Successful in 3m19s
release / release (версия и заметки) (push) Successful in 8s
v0.7.3 crawled for 35 minutes at the 6 GB cap and was killed without a
log. The container now gets 7 GB, the Gradle heap is 2 GB, the Metro
node process is capped at 1.5 GB and the job times out after 60 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:12:54 +03:00
mrleo1nidandClaude Opus 5.5 ed0302d90f docs(uploads): import writes only the final track file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:11:56 +03:00
mrleo1nidandClaude Opus 5.5 85fceaa686 fix(storage-agent): write nothing but the music files to the mounted storage
ci / api / api (push) Successful in 50s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Failing after 10s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 31m43s
ci / db / db (push) Failing after 19s
- Imported tracks are created directly under their final name with
  O_EXCL (never overwriting; " (2)" on conflict), fsync'ed and checked
  against SHA-256 — no hidden .pipu-tmp file next to them any more.
- On any failure the agent removes its own partial file and the empty
  directories it created for it.
- The write check uses access(W_OK) and the read-only mount flag instead
  of creating and deleting .pipu-write-test in the storage root.
- Tests assert the storage tree holds only audio files after successful
  and failed writes and after the write check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:22:49 +03:00
36 changed files with 1975 additions and 314 deletions

No files matched your search

+6 -2
View File
@@ -48,6 +48,8 @@ jobs:
android:
name: android (APK в релиз)
needs: release
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
timeout-minutes: 60
runs-on: ubuntu-latest
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
@@ -60,7 +62,7 @@ jobs:
image: docker.gitea.com/runner-images:ubuntu-latest
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
options: --cpus=3 --memory=6g --memory-swap=6g
options: --cpus=3 --memory=7g --memory-swap=7g
volumes:
- /opt/ci-cache/gradle:/root/.gradle
- /opt/ci-cache/android:/root/.android
@@ -106,6 +108,8 @@ jobs:
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
CMAKE_BUILD_PARALLEL_LEVEL: "2"
NINJAFLAGS: "-j2"
# JS-бандл (Metro) собирается внутри Gradle отдельным node — не даём ему разрастись.
NODE_OPTIONS: "--max-old-space-size=1536"
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
@@ -120,7 +124,7 @@ jobs:
cd android
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2048m -XX:MaxMetaspaceSize=512m")
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
if grep -q '^newArchEnabled=true' gradle.properties; then
"${gradle[@]}" generateCodegenArtifactsFromSchema
+85
View File
@@ -1256,6 +1256,38 @@ paths:
description: Снято
default:
$ref: "#/components/responses/Problem"
/api/v1/onboarding:
get:
operationId: getOnboarding
tags: [wave]
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора
description: |
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
responses:
"200":
description: Варианты и состояние
content:
application/json:
schema:
$ref: "#/components/schemas/Onboarding"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/onboarding:
put:
operationId: saveOnboarding
tags: [wave]
summary: Сохранить выбор онбординга (или пропуск — пустые списки)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/OnboardingChoice"
responses:
"204":
description: Сохранено
default:
$ref: "#/components/responses/Problem"
/api/v1/wave/options:
get:
operationId: getWaveOptions
@@ -1442,6 +1474,59 @@ components:
schema:
$ref: "#/components/schemas/Problem"
schemas:
Onboarding:
type: object
required: [required, artists, genres]
properties:
required:
type: boolean
artists:
type: array
items:
type: object
required: [id, name, track_count, cover_url, cover_colors]
properties:
id:
type: string
format: uuid
name:
type: string
track_count:
type: integer
cover_url:
type: string
nullable: true
description: Обложка самого большого альбома исполнителя
cover_colors:
type: array
items:
type: string
genres:
type: array
items:
type: object
required: [name, track_count]
properties:
name:
type: string
track_count:
type: integer
OnboardingChoice:
type: object
required: [artist_ids, genres]
properties:
artist_ids:
type: array
maxItems: 50
items:
type: string
format: uuid
genres:
type: array
maxItems: 30
items:
type: string
maxLength: 100
UserRef:
type: object
required: [id, username]
+23 -2
View File
@@ -1,5 +1,5 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -24,6 +24,7 @@ import {
import { cx, FormError, Icon } from '../shared/ui/ui';
import { WaveButton } from '../widgets/wave/WaveButton';
import s from './home.module.css';
import { Onboarding } from './Onboarding';
/** Иконки Material Symbols по именам из /wave/options. */
const ICONS: Record<string, string> = {
@@ -44,8 +45,28 @@ const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
type Tr = (key: string) => string;
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
export function HomePage() {
const session = useSession();
const queryClient = useQueryClient();
const onboarding = useQuery({
queryKey: ['onboarding'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: Infinity,
});
if (onboarding.data?.required) {
return (
<Onboarding
options={onboarding.data}
onDone={() => queryClient.setQueryData(['onboarding'], { ...onboarding.data, required: false })}
/>
);
}
return <WaveHome />;
}
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
function WaveHome() {
const { t } = useTranslation();
const tr = t as unknown as Tr;
const session = useSession();
+113
View File
@@ -0,0 +1,113 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { checkIcon } from '../shared/ui/icons';
import { Button, cx, FormError, Icon } from '../shared/ui/ui';
import s from './onboarding.module.css';
type Options = components['schemas']['Onboarding'];
/** Минимум исполнителей, чтобы волне было от чего оттолкнуться. */
const MIN_ARTISTS = 3;
/**
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
*/
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [artists, setArtists] = useState<Set<string>>(new Set());
const [genres, setGenres] = useState<Set<string>>(new Set());
const save = useMutation({
mutationFn: async (skip: boolean) =>
unwrap(
await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] },
}),
),
onSuccess: onDone,
});
// Функциональное обновление: быстрые нажатия подряд не теряют выбор.
const toggle = (update: (fn: (prev: Set<string>) => Set<string>) => void, key: string) =>
update((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0);
return (
<div className={s.page}>
<header className={s.head}>
<h1>{t('onboarding.title')}</h1>
<p>{t('onboarding.subtitle')}</p>
</header>
<section aria-label={t('onboarding.artists')}>
<h2 className={s.label}>{t('onboarding.artists')}</h2>
<div className={s.artists}>
{options.artists.map((a) => {
const on = artists.has(a.id);
return (
<button
key={a.id}
type="button"
className={cx(s.artist, on && s.on)}
aria-pressed={on}
onClick={() => toggle(setArtists, a.id)}
>
<span className={s.avatar}>
<Cover album={{ id: a.id, cover_url: a.cover_url, cover_colors: a.cover_colors }} size={96} round />
{on && (
<span className={s.check}>
<Icon src={checkIcon} size={22} />
</span>
)}
</span>
<span className={s.name}>{a.name}</span>
</button>
);
})}
</div>
</section>
{options.genres.length > 0 && (
<section aria-label={t('onboarding.genres')}>
<h2 className={s.label}>{t('onboarding.genres')}</h2>
<div className={s.genres}>
{options.genres.map((g) => (
<button
key={g.name}
type="button"
className={s.genre}
aria-pressed={genres.has(g.name)}
onClick={() => toggle(setGenres, g.name)}
>
{g.name}
</button>
))}
</div>
</section>
)}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<div className={s.actions}>
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}>
{t('onboarding.skip')}
</Button>
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}>
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
</Button>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
.page {
display: flex;
flex-direction: column;
gap: 28px;
max-width: 1100px;
margin: 0 auto;
animation: pipuIn 0.2s ease-out;
}
.head h1 {
margin: 0 0 8px;
font-family: var(--font-display);
font-size: 32px;
font-weight: 600;
letter-spacing: -0.02em;
}
.head p {
margin: 0;
color: var(--text2);
}
.label {
margin: 0 0 14px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.artists {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 18px 12px;
}
.artist {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 10px 6px;
border-radius: var(--radius-lg);
text-align: center;
}
.artist:hover {
background: var(--hover);
}
.avatar {
position: relative;
border-radius: 50%;
transition:
box-shadow 0.15s,
transform 0.15s;
}
.on .avatar {
box-shadow:
0 0 0 3px var(--bg),
0 0 0 5px var(--accent);
transform: scale(0.96);
}
.check {
position: absolute;
right: -2px;
bottom: -2px;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
color: #fff;
box-shadow: var(--glow);
}
.name {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.genres {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.genre {
height: 38px;
padding: 0 16px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
font-weight: 600;
font-size: 14px;
}
.genre[aria-pressed='true'] {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: var(--glow);
}
.actions {
position: sticky;
bottom: 16px;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px;
border-radius: var(--radius-lg);
border: 1px solid var(--line2);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
}
+1 -1
View File
@@ -103,7 +103,7 @@ container:
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
```
Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=6g` без свопа, CMake / ninja / Gradle
Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=7g` без свопа, Gradle `-Xmx2g`, Metro `--max-old-space-size=1536`, таймаут 60 мин, CMake / ninja / Gradle
по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там:
фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
+6 -4
View File
@@ -35,8 +35,7 @@
`fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента.
- Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`,
задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`.
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет запись: создаёт и удаляет
`.pipu-write-test` в корне.
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет право записи — без создания файлов.
- У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре
и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)).
@@ -44,8 +43,11 @@
- Агент: `POST /internal/files` `{source_id, local_path?, rel_path, staging_path, sha256}` → `{rel_path, size, renamed}`;
`POST /internal/files/test` — проверка записи. NFS с `accepts_uploads` монтируется rw в `/library-rw/<id>` (каталог
внутри контейнера агента), а в `/library/<id>` — bind этой точки с `remount,ro`. Local-источники пишутся через
`/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). Итоговое имя создаётся жёсткой ссылкой
на временный файл (`link` не перезаписывает, в отличие от `rename`); занято — ` (2)`, ` (3)`…
`/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). На музыкальный диск не пишется ничего,
кроме самих треков: файл создаётся сразу под итоговым именем (`O_EXCL` — существующий не перезаписывается;
занято — ` (2)`, ` (3)`…), `fsync`, сверка SHA-256; при ошибке агент удаляет свой недописанный файл и созданные
для него пустые каталоги. Проверка записи — `access(W_OK)` и флаг read-only файловой системы, без пробного файла.
Остальные сервисы видят музыку только на чтение (`read_only` в compose).
Владелец новых файлов и каталогов — `PIPU_FILE_UID` / `PIPU_FILE_GID` (`-1` — не менять).
- Шаблон: `meta.ParseTemplate` / `Render` (очистка имён, пределы длины), `meta.SuggestTemplate` (регэксп → шаблон через
`regexp/syntax`: именованные группы → поля, `(?:(?P<disc>\d)-)?` → `{disc:1-}`, необязательная группа → `[ ]`,
+17
View File
@@ -152,6 +152,14 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
Обучение — ночью; новая модель активируется, только если офлайн-NDCG@10 не хуже активной более чем на 2%.
Пока размеченных показов меньше ~20k, используется v1 (переключение автоматическое по порогу).
Реализация (`pipu_rec/train/ranker.py`): при каждом показе волна пишет вектор признаков кандидата
в `rec_impression_features` (29 признаков, `FEATURE_NAMES` в `wave/engine.py`; порядок версионируется
`FEATURES_VERSION`). Ночью признаки соединяются с исходами `wave_impressions`, группа — сессия волны;
валидация — сессии последних 7 дней. Модель включается, если её NDCG@10 не ниже порядка по оценке v1
и не хуже активной более чем на 2%. Модель хранится строкой в `model_versions.artifact` (kind `ranker`),
сервис загружает активную при старте и после ночного обучения; в выдаче её порядок заменяет оценку v1,
MMR-разнообразие и квоты остаются. Пока модели нет, `model_ver` показа — версия v1.
### 6.4 Занятия и настроения
Каждый контекст — профиль целевых значений аудиопризнаков (`services/recommender/config/contexts.yaml`,
@@ -191,6 +199,11 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
что одному пользователю лучше заходит `fresh`, а другому — `rediscover`. Параметры затухают
(×0.99 в день), чтобы подстраиваться под смену вкуса.
Реализация (`pipu_rec/train/bandit.py`): параметры пересчитываются ночью по `wave_impressions` за 90 дней
(награда — `complete` / `like`) в `user_profiles_rec.bandit`; при сборке пачки источник получает бонус
`0.2·(θ − 0.5)`, где θ ~ Beta(α, β) (источник без наблюдений — нейтральные 0.5). `exploration` следует за
успехом `fresh` относительно остальных источников (0.05–0.4).
### 6.7 Реакция в реальном времени
- Клиент держит в очереди 2 трека вперёд и запрашивает новую пачку, когда осталось ≤ 2.
@@ -204,6 +217,10 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
- **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды.
Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки
(по одному треку из разных кластеров), exploration 40% на первые 30 треков.
- Реализация онбординга: `GET /onboarding` (40 исполнителей с наибольшим числом треков, 24 жанра; `required` —
не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор —
пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1,
жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны.
- **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания.
## 8. Обучение на всех пользователях
+3 -3
View File
@@ -161,9 +161,9 @@
2. Запросить у `storage-agent` запись: `POST /internal/files` `{source_id, rel_path, staging_path}`.
Агент:
- создаёт каталоги;
- копирует во временный файл `.<name>.pipu-tmp` в целевой папке, `fsync`;
- сверяет SHA-256 копии со staging;
- атомарно переименовывает в итоговое имя.
- создаёт файл сразу под итоговым именем (`O_EXCL`, существующий не перезаписывается), копирует, `fsync`;
- сверяет SHA-256 копии со staging; при ошибке удаляет свой файл и созданные пустые каталоги —
на музыкальный диск не попадает ничего, кроме треков.
3. Запустить точечный скан файла (`scan_path`) → трек появляется в каталоге и поиске, ставится в аудиоанализ.
В `media_files` сохраняется `upload_id` — история происхождения.
4. Удалить файл из staging, освободить квоту, статус `imported`, запись в `audit_log`.
+101
View File
@@ -1066,6 +1066,43 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/onboarding": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Онбординг вкуса — исполнители и жанры библиотеки для выбора
* @description `required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
*/
get: operations["getOnboarding"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/onboarding": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Сохранить выбор онбординга (или пропуск — пустые списки) */
put: operations["saveOnboarding"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/wave/options": {
parameters: {
query?: never;
@@ -1181,6 +1218,26 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
Onboarding: {
required: boolean;
artists: {
/** Format: uuid */
id: string;
name: string;
track_count: number;
/** @description Обложка самого большого альбома исполнителя */
cover_url: string | null;
cover_colors: string[];
}[];
genres: {
name: string;
track_count: number;
}[];
};
OnboardingChoice: {
artist_ids: string[];
genres: string[];
};
UserRef: {
/** Format: uuid */
id: string;
@@ -3599,6 +3656,50 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
getOnboarding: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Варианты и состояние */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Onboarding"];
};
};
default: components["responses"]["Problem"];
};
};
saveOnboarding: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["OnboardingChoice"];
};
};
responses: {
/** @description Сохранено */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
getWaveOptions: {
parameters: {
query?: never;
+12
View File
@@ -278,5 +278,17 @@
"cache": "Recent tracks cache",
"cache_clear": "Clear ({{size}} MB)",
"cache_pending": "Will be cleared on next launch"
},
"onboarding": {
"title": "What do you like to listen to?",
"subtitle": "Pick a few artists and genres to start My Wave. After that it learns from what you play.",
"artists": "Artists",
"genres": "Genres",
"skip": "Skip",
"done": "Done — to the wave",
"pick_more_one": "Pick {{count}} more artist",
"pick_more_few": "Pick {{count}} more artists",
"pick_more_many": "Pick {{count}} more artists",
"pick_more_other": "Pick {{count}} more artists"
}
}
+12
View File
@@ -278,5 +278,17 @@
"cache": "Кэш недавних треков",
"cache_clear": "Очистить ({{size}} МБ)",
"cache_pending": "Очистится при следующем запуске"
},
"onboarding": {
"title": "Что вы любите слушать?",
"subtitle": "Отметьте несколько исполнителей и жанров — с них начнётся «Моя волна». Дальше она будет учиться на ваших прослушиваниях.",
"artists": "Исполнители",
"genres": "Жанры",
"skip": "Пропустить",
"done": "Готово — к волне",
"pick_more_one": "Выберите ещё {{count}} исполнителя",
"pick_more_few": "Выберите ещё {{count}} исполнителя",
"pick_more_many": "Выберите ещё {{count}} исполнителей",
"pick_more_other": "Выберите ещё {{count}} исполнителя"
}
}
+13
View File
@@ -271,6 +271,19 @@ func TestUserJourney(t *testing.T) {
c.ok("GET", "/api/v1/me/likes", nil, &likes)
require.Equal(t, 1, likes.Total)
// 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается.
var onb struct {
Required bool `json:"required"`
Artists []struct {
ID string `json:"id"`
} `json:"artists"`
}
c.ok("GET", "/api/v1/onboarding", nil, &onb)
require.NotEmpty(t, onb.Artists)
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{onb.Artists[0].ID}, "genres": []string{}}, nil)
c.ok("GET", "/api/v1/onboarding", nil, &onb)
require.False(t, onb.Required)
// 8. «Моя волна»: рекомендатель недоступен — запасная волна из библиотеки.
var wave struct {
SessionID string `json:"session_id"`
+3 -3
View File
@@ -6,6 +6,7 @@ tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
require (
github.com/caarlos0/env/v11 v11.3.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/getkin/kin-openapi v0.127.0
github.com/go-chi/chi/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.2.2
@@ -16,11 +17,12 @@ require (
github.com/redis/go-redis/v9 v9.10.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0
golang.org/x/sys v0.33.0
golang.org/x/text v0.25.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
@@ -43,8 +45,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+448 -235
View File
@@ -488,6 +488,30 @@ type Me struct {
Username string `json:"username"`
}
// Onboarding defines model for Onboarding.
type Onboarding struct {
Artists []struct {
CoverColors []string `json:"cover_colors"`
// CoverUrl Обложка самого большого альбома исполнителя
CoverUrl *string `json:"cover_url"`
Id openapi_types.UUID `json:"id"`
Name string `json:"name"`
TrackCount int `json:"track_count"`
} `json:"artists"`
Genres []struct {
Name string `json:"name"`
TrackCount int `json:"track_count"`
} `json:"genres"`
Required bool `json:"required"`
}
// OnboardingChoice defines model for OnboardingChoice.
type OnboardingChoice struct {
ArtistIds []openapi_types.UUID `json:"artist_ids"`
Genres []string `json:"genres"`
}
// PathPattern defines model for PathPattern.
type PathPattern struct {
IsEnabled bool `json:"is_enabled"`
@@ -1394,6 +1418,9 @@ type UpdateMeJSONRequestBody = UpdateMeRequest
// SetBootstrapCredentialsJSONRequestBody defines body for SetBootstrapCredentials for application/json ContentType.
type SetBootstrapCredentialsJSONRequestBody = BootstrapCredentialsRequest
// SaveOnboardingJSONRequestBody defines body for SaveOnboarding for application/json ContentType.
type SaveOnboardingJSONRequestBody = OnboardingChoice
// ChangePasswordJSONRequestBody defines body for ChangePassword for application/json ContentType.
type ChangePasswordJSONRequestBody = ChangePasswordRequest
@@ -1564,6 +1591,9 @@ type ServerInterface interface {
// Лайки, новые сверху
// (GET /api/v1/me/likes)
GetLikes(w http.ResponseWriter, r *http.Request, params GetLikesParams)
// Сохранить выбор онбординга (или пропуск — пустые списки)
// (PUT /api/v1/me/onboarding)
SaveOnboarding(w http.ResponseWriter, r *http.Request)
// Сменить пароль
// (POST /api/v1/me/password)
ChangePassword(w http.ResponseWriter, r *http.Request)
@@ -1585,6 +1615,9 @@ type ServerInterface interface {
// Завершить сессию на устройстве
// (DELETE /api/v1/me/sessions/{id})
RevokeMySession(w http.ResponseWriter, r *http.Request, id openapi_types.UUID)
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
// (GET /api/v1/onboarding)
GetOnboarding(w http.ResponseWriter, r *http.Request)
// Страница поиска
// (GET /api/v1/search)
Search(w http.ResponseWriter, r *http.Request, params SearchParams)
@@ -1927,6 +1960,12 @@ func (_ Unimplemented) GetLikes(w http.ResponseWriter, r *http.Request, params G
w.WriteHeader(http.StatusNotImplemented)
}
// Сохранить выбор онбординга (или пропуск — пустые списки)
// (PUT /api/v1/me/onboarding)
func (_ Unimplemented) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Сменить пароль
// (POST /api/v1/me/password)
func (_ Unimplemented) ChangePassword(w http.ResponseWriter, r *http.Request) {
@@ -1969,6 +2008,12 @@ func (_ Unimplemented) RevokeMySession(w http.ResponseWriter, r *http.Request, i
w.WriteHeader(http.StatusNotImplemented)
}
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
// (GET /api/v1/onboarding)
func (_ Unimplemented) GetOnboarding(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Страница поиска
// (GET /api/v1/search)
func (_ Unimplemented) Search(w http.ResponseWriter, r *http.Request, params SearchParams) {
@@ -3519,6 +3564,26 @@ func (siw *ServerInterfaceWrapper) GetLikes(w http.ResponseWriter, r *http.Reque
handler.ServeHTTP(w, r)
}
// SaveOnboarding operation middleware
func (siw *ServerInterfaceWrapper) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.SaveOnboarding(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ChangePassword operation middleware
func (siw *ServerInterfaceWrapper) ChangePassword(w http.ResponseWriter, r *http.Request) {
@@ -3692,6 +3757,26 @@ func (siw *ServerInterfaceWrapper) RevokeMySession(w http.ResponseWriter, r *htt
handler.ServeHTTP(w, r)
}
// GetOnboarding operation middleware
func (siw *ServerInterfaceWrapper) GetOnboarding(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetOnboarding(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// Search operation middleware
func (siw *ServerInterfaceWrapper) Search(w http.ResponseWriter, r *http.Request) {
@@ -4497,6 +4582,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/me/likes", wrapper.GetLikes)
})
r.Group(func(r chi.Router) {
r.Put(options.BaseURL+"/api/v1/me/onboarding", wrapper.SaveOnboarding)
})
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/me/password", wrapper.ChangePassword)
})
@@ -4518,6 +4606,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Delete(options.BaseURL+"/api/v1/me/sessions/{id}", wrapper.RevokeMySession)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/onboarding", wrapper.GetOnboarding)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/search", wrapper.Search)
})
@@ -5956,6 +6047,34 @@ func (response GetLikesdefaultApplicationProblemPlusJSONResponse) VisitGetLikesR
return json.NewEncoder(w).Encode(response.Body)
}
type SaveOnboardingRequestObject struct {
Body *SaveOnboardingJSONRequestBody
}
type SaveOnboardingResponseObject interface {
VisitSaveOnboardingResponse(w http.ResponseWriter) error
}
type SaveOnboarding204Response struct {
}
func (response SaveOnboarding204Response) VisitSaveOnboardingResponse(w http.ResponseWriter) error {
w.WriteHeader(204)
return nil
}
type SaveOnboardingdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response SaveOnboardingdefaultApplicationProblemPlusJSONResponse) VisitSaveOnboardingResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type ChangePasswordRequestObject struct {
Body *ChangePasswordJSONRequestBody
}
@@ -6155,6 +6274,34 @@ func (response RevokeMySessiondefaultApplicationProblemPlusJSONResponse) VisitRe
return json.NewEncoder(w).Encode(response.Body)
}
type GetOnboardingRequestObject struct {
}
type GetOnboardingResponseObject interface {
VisitGetOnboardingResponse(w http.ResponseWriter) error
}
type GetOnboarding200JSONResponse Onboarding
func (response GetOnboarding200JSONResponse) VisitGetOnboardingResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetOnboardingdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response GetOnboardingdefaultApplicationProblemPlusJSONResponse) VisitGetOnboardingResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type SearchRequestObject struct {
Params SearchParams
}
@@ -6812,6 +6959,9 @@ type StrictServerInterface interface {
// Лайки, новые сверху
// (GET /api/v1/me/likes)
GetLikes(ctx context.Context, request GetLikesRequestObject) (GetLikesResponseObject, error)
// Сохранить выбор онбординга (или пропуск — пустые списки)
// (PUT /api/v1/me/onboarding)
SaveOnboarding(ctx context.Context, request SaveOnboardingRequestObject) (SaveOnboardingResponseObject, error)
// Сменить пароль
// (POST /api/v1/me/password)
ChangePassword(ctx context.Context, request ChangePasswordRequestObject) (ChangePasswordResponseObject, error)
@@ -6833,6 +6983,9 @@ type StrictServerInterface interface {
// Завершить сессию на устройстве
// (DELETE /api/v1/me/sessions/{id})
RevokeMySession(ctx context.Context, request RevokeMySessionRequestObject) (RevokeMySessionResponseObject, error)
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
// (GET /api/v1/onboarding)
GetOnboarding(ctx context.Context, request GetOnboardingRequestObject) (GetOnboardingResponseObject, error)
// Страница поиска
// (GET /api/v1/search)
Search(ctx context.Context, request SearchRequestObject) (SearchResponseObject, error)
@@ -8220,6 +8373,37 @@ func (sh *strictHandler) GetLikes(w http.ResponseWriter, r *http.Request, params
}
}
// SaveOnboarding operation middleware
func (sh *strictHandler) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
var request SaveOnboardingRequestObject
var body SaveOnboardingJSONRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
return
}
request.Body = &body
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.SaveOnboarding(ctx, request.(SaveOnboardingRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "SaveOnboarding")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(SaveOnboardingResponseObject); ok {
if err := validResponse.VisitSaveOnboardingResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// ChangePassword operation middleware
func (sh *strictHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
var request ChangePasswordRequestObject
@@ -8415,6 +8599,30 @@ func (sh *strictHandler) RevokeMySession(w http.ResponseWriter, r *http.Request,
}
}
// GetOnboarding operation middleware
func (sh *strictHandler) GetOnboarding(w http.ResponseWriter, r *http.Request) {
var request GetOnboardingRequestObject
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetOnboarding(ctx, request.(GetOnboardingRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetOnboarding")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetOnboardingResponseObject); ok {
if err := validResponse.VisitGetOnboardingResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// Search operation middleware
func (sh *strictHandler) Search(w http.ResponseWriter, r *http.Request, params SearchParams) {
var request SearchRequestObject
@@ -8911,241 +9119,246 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"YSFPodutPb77+P8FAAD//9wKjL6lLQEA",
"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",
}
// GetSwagger returns the content of the embedded swagger specification file
+5
View File
@@ -77,6 +77,7 @@ func (m *Meili) wait(ctx context.Context, t taskRef) error {
Status string `json:"status"`
Error *struct {
Message string `json:"message"`
Code string `json:"code"`
} `json:"error"`
}
if err := m.do(ctx, http.MethodGet, fmt.Sprintf("/tasks/%d", t.TaskUID), nil, &task); err != nil {
@@ -88,6 +89,10 @@ func (m *Meili) wait(ctx context.Context, t taskRef) error {
case "failed", "canceled":
msg := task.Status
if task.Error != nil {
// Удаление отсутствующего индекса принимается (202) и падает уже в задаче.
if task.Error.Code == "index_not_found" {
return fmt.Errorf("%w: task %d: %s", ErrMeiliNotFound, t.TaskUID, task.Error.Message)
}
msg = task.Error.Message
}
return fmt.Errorf("meilisearch: task %d: %s", t.TaskUID, msg)
@@ -0,0 +1,28 @@
package search
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// На чистом Meilisearch удаление несуществующего индекса падает в задаче, а не ответом 404.
func TestCallMapsFailedIndexTaskToNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodDelete {
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"taskUid":0}`))
return
}
_, _ = w.Write([]byte(`{"status":"failed","error":{"code":"index_not_found","message":"Index ` + "`x`" + ` not found."}}`))
}))
defer srv.Close()
err := NewMeili(srv.URL, "").call(context.Background(), http.MethodDelete, "/indexes/x", nil)
if !errors.Is(err, ErrMeiliNotFound) {
t.Fatalf("err = %v, want ErrMeiliNotFound", err)
}
}
@@ -0,0 +1,20 @@
//go:build !unix
package storageagent
import (
"errors"
"os"
)
// canWrite — запасная проверка для не-Unix (только разработка): бит записи у каталога.
func canWrite(dir string) error {
info, err := os.Stat(dir)
if err != nil {
return err
}
if info.Mode().Perm()&0o200 == 0 {
return errors.New("directory is not writable")
}
return nil
}
@@ -0,0 +1,18 @@
//go:build unix
package storageagent
import (
"errors"
"golang.org/x/sys/unix"
)
// canWrite — можно ли создавать файлы в каталоге (без пробной записи); read-only монтирование — ошибка.
func canWrite(dir string) error {
var st unix.Statfs_t
if err := unix.Statfs(dir, &st); err == nil && st.Flags&unix.ST_RDONLY != 0 {
return errors.New("filesystem is mounted read-only")
}
return unix.Access(dir, unix.W_OK|unix.X_OK)
}
+50 -46
View File
@@ -142,27 +142,19 @@ func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, res)
}
// write копирует staging во временный файл рядом с целью, fsync, сверяет SHA-256 и создаёт итоговое имя
// жёсткой ссылкой (не перезаписывает существующий файл, в отличие от rename), затем убирает временный.
// write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
// конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме самого трека:
// ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
// каталоги удаляются — агент убирает только то, что создал сам.
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
target := filepath.Join(root, filepath.FromSlash(rel))
dir := filepath.Dir(target)
if err := a.mkdirs(root, dir); err != nil {
return WriteResult{}, err
}
tmp := filepath.Join(dir, "."+filepath.Base(target)+".pipu-tmp")
size, err := a.copyTemp(staging, tmp)
if err != nil {
_ = os.Remove(tmp)
return WriteResult{}, err
}
defer func() { _ = os.Remove(tmp) }()
got, err := fileSHA256(tmp)
dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
created, err := a.mkdirs(root, dir)
if err != nil {
return WriteResult{}, err
}
if !bytes.Equal(got, want) {
return WriteResult{}, fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
fail := func(err error) (WriteResult, error) {
removeEmpty(created)
return WriteResult{}, err
}
ext := path.Ext(rel)
@@ -172,24 +164,39 @@ func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, erro
if n > 1 {
name = fmt.Sprintf("%s (%d)%s", base, n, ext)
}
err := os.Link(tmp, filepath.Join(root, filepath.FromSlash(name)))
target := filepath.Join(root, filepath.FromSlash(name))
dst, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if os.IsExist(err) {
continue
}
if err != nil {
return fail(fmt.Errorf("create: %w", err))
}
size, err := copyInto(dst, staging)
if err == nil {
syncDir(dir)
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
var got []byte
if got, err = fileSHA256(target); err == nil && !bytes.Equal(got, want) {
err = fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
}
}
if !os.IsExist(err) {
return WriteResult{}, fmt.Errorf("link: %w", err)
if err != nil {
_ = os.Remove(target) // наш собственный недописанный / испорченный файл
return fail(err)
}
a.chown(target)
syncDir(dir)
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
}
return WriteResult{}, errors.New("too many files with the same name")
return fail(errors.New("too many files with the same name"))
}
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID).
func (a *agent) mkdirs(root, dir string) error {
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID) и возвращает созданные.
func (a *agent) mkdirs(root, dir string) ([]string, error) {
rel, err := filepath.Rel(root, dir)
if err != nil || strings.HasPrefix(rel, "..") {
return errBadPath
return nil, errBadPath
}
var created []string
cur := root
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
if seg == "." || seg == "" {
@@ -200,24 +207,30 @@ func (a *agent) mkdirs(root, dir string) error {
if os.IsExist(err) {
continue
}
return fmt.Errorf("mkdir: %w", err)
removeEmpty(created)
return nil, fmt.Errorf("mkdir: %w", err)
}
created = append(created, cur)
a.chown(cur)
}
return nil
return created, nil
}
func (a *agent) copyTemp(staging, tmp string) (int64, error) {
// removeEmpty удаляет созданные каталоги от глубоких к верхним; os.Remove не трогает непустые.
func removeEmpty(dirs []string) {
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
}
// copyInto копирует staging в открытый файл и делает fsync; файл закрывается.
func copyInto(dst *os.File, staging string) (int64, error) {
src, err := os.Open(staging)
if err != nil {
_ = dst.Close()
return 0, fmt.Errorf("staging: %w", err)
}
defer func() { _ = src.Close() }()
_ = os.Remove(tmp) // остаток прерванной попытки
dst, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return 0, fmt.Errorf("create temp: %w", err)
}
n, err := io.Copy(dst, src)
if err == nil {
err = dst.Sync()
@@ -228,7 +241,6 @@ func (a *agent) copyTemp(staging, tmp string) (int64, error) {
if err != nil {
return 0, fmt.Errorf("copy: %w", err)
}
a.chown(tmp)
return n, nil
}
@@ -240,7 +252,8 @@ func (a *agent) chown(p string) {
}
}
// testWrite создаёт и удаляет .pipu-write-test в корне источника на запись.
// testWrite проверяет право записи в корень источника, ничего не создавая на диске (access(W_OK);
// на NFS ответ даёт сам сервер с учётом root_squash и прав экспорта).
func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
var req WriteTestRequest
if !a.decode(w, r, &req) {
@@ -251,16 +264,7 @@ func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, WriteTestResult{Message: err.Error()})
return
}
p := filepath.Join(root, ".pipu-write-test")
f, err := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err == nil {
_, err = f.WriteString("pipu")
if cerr := f.Close(); err == nil {
err = cerr
}
}
_ = os.Remove(p)
if err != nil {
if err := canWrite(root); err != nil {
writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()})
return
}
@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -74,6 +75,7 @@ func TestWriteFile(t *testing.T) {
assert.True(t, res.Renamed)
entries, _ := os.ReadDir(dir)
assert.Len(t, entries, 2)
assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID))
// Local-источник пишется в LIBRARY_HOST.
local := "music"
@@ -103,8 +105,21 @@ func TestWriteFileRejects(t *testing.T) {
other := sha256.Sum256([]byte("other"))
rec := e.post("/internal/files", WriteRequest{SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: hex.EncodeToString(other[:])})
assert.Equal(t, http.StatusInternalServerError, rec.Code)
entries, _ := os.ReadDir(filepath.Join(e.paths.LibraryRW, srcID, "A"))
assert.Empty(t, entries)
// Созданная под файл папка тоже убрана: на диске не остаётся следов неудачной записи.
assert.NoDirExists(t, filepath.Join(e.paths.LibraryRW, srcID, "A"))
assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID))
}
// assertOnlyMusic — в дереве хранилища только аудиофайлы и каталоги: ни временных, ни служебных файлов.
func assertOnlyMusic(t *testing.T, root string) {
t.Helper()
require.NoError(t, filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
assert.True(t, audioExt[strings.ToLower(filepath.Ext(p))] && !strings.HasPrefix(d.Name(), "."), "unexpected file %s", p)
return nil
}))
}
func TestWritableMount(t *testing.T) {
@@ -120,7 +135,8 @@ func TestWritableMount(t *testing.T) {
var res WriteTestResult
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
assert.True(t, res.OK, res.Message)
assert.NoFileExists(t, filepath.Join(rw, ".pipu-write-test"))
entries, _ := os.ReadDir(rw)
assert.Empty(t, entries, "write check creates nothing")
// Без accepts_uploads rw-точка снимается.
e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}})
+141
View File
@@ -0,0 +1,141 @@
package wave
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
)
// Онбординг вкуса (docs/recommendations.md#7): новый пользователь отмечает исполнителей и жанры — рекомендатель
// берёт их как начальный вкус, пока своей истории мало. Пропуск сохраняется пустым выбором.
const (
onboardingArtists = 40
onboardingGenres = 24
// История, после которой онбординг уже не предлагается: вкус и так виден по прослушиваниям.
onboardingMinHistory = 10
)
// GetOnboarding реализует gen.StrictServerInterface.
func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestObject) (gen.GetOnboardingResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
out := gen.GetOnboarding200JSONResponse{}
var done bool
var plays int
if err := h.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM user_profiles_rec WHERE user_id = $1 AND onboarding IS NOT NULL),
(SELECT count(*) FROM user_track_stats WHERE user_id = $1)`, p.UserID).Scan(&done, &plays); err != nil {
return nil, fmt.Errorf("wave: onboarding state: %w", err)
}
rows, err := h.pool.Query(ctx, `
SELECT a.id, a.name, count(*) AS n,
(SELECT al.id FROM albums al WHERE al.album_artist_id = a.id
ORDER BY (SELECT count(*) FROM tracks t WHERE t.album_id = al.id) DESC, al.id LIMIT 1)
FROM artists a JOIN track_artists ta ON ta.artist_id = a.id AND ta.role = 'main'
GROUP BY a.id ORDER BY n DESC, a.name LIMIT $1`, onboardingArtists)
if err != nil {
return nil, fmt.Errorf("wave: onboarding artists: %w", err)
}
type artist struct {
id, album *uuid.UUID
name string
n int
}
var artists []artist
for rows.Next() {
var a artist
var id uuid.UUID
if err := rows.Scan(&id, &a.name, &a.n, &a.album); err != nil {
rows.Close()
return nil, fmt.Errorf("wave: onboarding artists: %w", err)
}
a.id = &id
artists = append(artists, a)
}
rows.Close()
dto := h.dto()
for _, a := range artists {
item := struct {
CoverColors []string `json:"cover_colors"`
CoverUrl *string `json:"cover_url"`
Id uuid.UUID `json:"id"`
Name string `json:"name"`
TrackCount int `json:"track_count"`
}{Id: *a.id, Name: a.name, TrackCount: a.n, CoverColors: []string{}}
if a.album != nil {
if al, err := h.catalog.Album(ctx, *a.album); err == nil {
sum := dto.AlbumSummary(al)
item.CoverUrl, item.CoverColors = sum.CoverUrl, sum.CoverColors
}
}
out.Artists = append(out.Artists, item)
}
grows, err := h.pool.Query(ctx, `
SELECT g.name, count(DISTINCT tg.track_id) AS n FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
GROUP BY g.name ORDER BY n DESC, g.name LIMIT $1`, onboardingGenres)
if err != nil {
return nil, fmt.Errorf("wave: onboarding genres: %w", err)
}
for grows.Next() {
var g struct {
Name string `json:"name"`
TrackCount int `json:"track_count"`
}
if err := grows.Scan(&g.Name, &g.TrackCount); err != nil {
grows.Close()
return nil, fmt.Errorf("wave: onboarding genres: %w", err)
}
out.Genres = append(out.Genres, g)
}
grows.Close()
if out.Artists == nil {
out.Artists = []struct {
CoverColors []string `json:"cover_colors"`
CoverUrl *string `json:"cover_url"`
Id uuid.UUID `json:"id"`
Name string `json:"name"`
TrackCount int `json:"track_count"`
}{}
}
if out.Genres == nil {
out.Genres = []struct {
Name string `json:"name"`
TrackCount int `json:"track_count"`
}{}
}
out.Required = !done && plays < onboardingMinHistory && len(out.Artists) > 0
return out, nil
}
// SaveOnboarding реализует gen.StrictServerInterface.
func (h *Handler) SaveOnboarding(ctx context.Context, req gen.SaveOnboardingRequestObject) (gen.SaveOnboardingResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
ids := req.Body.ArtistIds
if ids == nil {
ids = []uuid.UUID{}
}
genres := req.Body.Genres
if genres == nil {
genres = []string{}
}
raw, _ := json.Marshal(map[string]any{"artist_ids": ids, "genres": genres})
if _, err := h.pool.Exec(ctx, `
INSERT INTO user_profiles_rec (user_id, onboarding, updated_at) VALUES ($1, $2, now())
ON CONFLICT (user_id) DO UPDATE SET onboarding = EXCLUDED.onboarding, updated_at = now()`,
p.UserID, raw); err != nil {
return nil, fmt.Errorf("wave: save onboarding: %w", err)
}
return gen.SaveOnboarding204Response{}, nil
}
@@ -0,0 +1,7 @@
-- +goose Up
-- Онбординг «Моей волны» (docs/recommendations.md#7): выбранные исполнители и жанры — начальный вкус,
-- пока своей истории мало. NULL — онбординг ещё не пройден и не пропущен.
ALTER TABLE user_profiles_rec ADD COLUMN onboarding jsonb;
-- +goose Down
ALTER TABLE user_profiles_rec DROP COLUMN onboarding;
+17
View File
@@ -0,0 +1,17 @@
-- +goose Up
-- Ранжировщик волны v2 (docs/recommendations.md#6.3): признаки кандидата в момент показа — для обучения
-- LightGBM на исходах показов (wave_impressions.outcome); артефакт модели хранится в model_versions.
CREATE TABLE rec_impression_features (
wave_session_id uuid NOT NULL REFERENCES wave_sessions(id) ON DELETE CASCADE,
track_id uuid NOT NULL,
features real[] NOT NULL,
served_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (wave_session_id, track_id)
);
CREATE INDEX rec_impression_features_served_idx ON rec_impression_features (served_at);
ALTER TABLE model_versions ADD COLUMN artifact text;
-- +goose Down
ALTER TABLE model_versions DROP COLUMN artifact;
DROP TABLE rec_impression_features;
+2 -2
View File
@@ -15,8 +15,8 @@ COPY services/recommender/ ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
FROM python:3.12-slim
# ffmpeg декодирует аудио для анализатора.
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 pipu
# ffmpeg декодирует аудио для анализатора; libgomp1 нужен LightGBM (ранжировщик волны).
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libgomp1 && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 pipu
COPY --from=build /app /app
ARG VERSION=0.0.0-dev
ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1
+1 -1
View File
@@ -30,7 +30,7 @@ def _serve(settings: Settings) -> None:
wave = WaveService(settings.database_url, settings.redis_url, load_contexts(settings.contexts_path))
if settings.train_hour >= 0:
# После активации новой модели каталог перечитывается сразу — горячая подмена векторов.
NightlyTrainer(settings.database_url, settings.train_hour, on_done=wave.index.refresh).start()
NightlyTrainer(settings.database_url, settings.train_hour, on_done=wave.reload).start()
app = create_app(settings, {"postgres": check_postgres, "redis": check_redis}, wave)
uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level)
@@ -0,0 +1,68 @@
"""Бандит по источникам кандидатов (docs/recommendations.md#6.6): Beta(α, β) на пару (пользователь, источник).
Награда — трек из источника доигран или лайкнут; скип и дизлайк — неудача. Наблюдения затухают ×0.99 в день,
чтобы бандит подстраивался под смену вкуса. Доля «нового» (exploration) следует за успехом источника `fresh`.
"""
from __future__ import annotations
import json
from collections import defaultdict
from typing import Any
import psycopg
DAILY_DECAY = 0.99
REWARD = {"complete": 1.0, "like": 1.0, "skip": 0.0, "skip_early": 0.0, "dislike": 0.0}
EXPLORATION_MIN, EXPLORATION_MAX = 0.05, 0.4
Params = dict[str, list[float]] # источник → [α, β]
def compute(rows: list[tuple[str, str, str, float]], now: float) -> dict[str, Params]:
"""rows: (user, source, outcome, served_at) → user → source → [α, β] с приором Beta(1, 1)."""
out: dict[str, Params] = defaultdict(dict)
for user, source, outcome, ts in rows:
r = REWARD.get(outcome)
if r is None:
continue
w = DAILY_DECAY ** max((now - ts) / 86400.0, 0.0)
ab = out[user].setdefault(source, [1.0, 1.0])
ab[0] += w * r
ab[1] += w * (1.0 - r)
return {u: {s: [round(a, 4), round(b, 4)] for s, (a, b) in p.items()} for u, p in out.items()}
def exploration(params: Params, default: float = 0.15) -> float:
"""Доля «нового»: успешнее `fresh` относительно остальных источников — больше исследования."""
fresh = params.get("fresh")
others = [a / (a + b) for s, (a, b) in params.items() if s != "fresh"]
if fresh is None or not others:
return default
rate = fresh[0] / (fresh[0] + fresh[1])
base = sum(others) / len(others)
value = default * (rate / base if base > 0 else 1.0)
return round(min(max(value, EXPLORATION_MIN), EXPLORATION_MAX), 3)
def update_bandits(conn: psycopg.Connection[Any], now: float) -> int:
"""Пересчитывает параметры по показам волны за 90 дней. Возвращает число пользователей."""
rows = conn.execute(
"""
SELECT ws.user_id::text, wi.candidate_source, wi.outcome, extract(epoch FROM wi.served_at)::float8
FROM wave_impressions wi JOIN wave_sessions ws ON ws.id = wi.wave_session_id
WHERE wi.outcome IS NOT NULL AND wi.served_at > now() - interval '90 days'
"""
).fetchall()
params = compute([(u, s, o, float(ts)) for u, s, o, ts in rows], now)
with conn.transaction(), conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO user_profiles_rec (user_id, bandit, exploration, updated_at)
VALUES (%s, %s, %s, now())
ON CONFLICT (user_id) DO UPDATE SET bandit = EXCLUDED.bandit, exploration = EXCLUDED.exploration,
updated_at = now()
""",
[(u, json.dumps(p), exploration(p)) for u, p in params.items()],
)
return len(params)
@@ -15,7 +15,9 @@ import numpy as np
import psycopg
from pipu_rec.train.als import ALSModel, normalize, train_als
from pipu_rec.train.bandit import update_bandits
from pipu_rec.train.metrics import evaluate
from pipu_rec.train.ranker import train_and_save
from pipu_rec.train.signals import DISLIKE, LIKE, Interaction, event_weight, implicit_scores
log = logging.getLogger(__name__)
@@ -157,9 +159,21 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
if not conn.execute("SELECT pg_try_advisory_lock(%s)", (ADVISORY_LOCK,)).fetchone()[0]: # type: ignore[index]
return {"skipped": "another training is running"}
try:
# Бандит считается всегда — ему хватает и пары сессий волны, в отличие от ALS.
bandit_users = update_bandits(conn, now)
# Ранжировщик v2 учится на показах волны и от числа событий ALS не зависит.
try:
ranker = train_and_save(conn, now)
except Exception as e: # обучение ранжировщика не должно мешать ALS
log.exception("ranker training failed")
ranker = {"ranker": {"error": str(e)}}
interactions = load_interactions(conn)
if len(interactions) < MIN_INTERACTIONS:
return {"skipped": f"only {len(interactions)} interactions"}
return {
"skipped": f"only {len(interactions)} interactions",
"bandit_users": bandit_users,
**ranker,
}
started = time.monotonic()
offline = offline_metrics(interactions, now)
model = train_als(implicit_scores(interactions, now))
@@ -171,6 +185,7 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
"users": len(model.users),
"items": len(model.items),
"interactions": len(interactions),
"bandit_users": bandit_users,
"train_s": round(time.monotonic() - started, 1),
}
if not should_activate(offline, active_metrics(conn, "als")):
@@ -180,10 +195,10 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
(version, json.dumps(metrics)),
)
log.warning("als %s not activated: offline metrics regressed %s", version, offline)
return {"version": version, "activated": False, **metrics}
return {"version": version, "activated": False, **metrics, **ranker}
save_als(conn, model, version, metrics)
log.info("als %s activated: %s", version, metrics)
return {"version": version, "activated": True, **metrics}
return {"version": version, "activated": True, **metrics, **ranker}
finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (ADVISORY_LOCK,))
@@ -214,7 +229,8 @@ class NightlyTrainer:
try:
result = train_all(self.dsn)
log.info("training finished: %s", result)
if result.get("activated") and self.on_done:
ranker_on = bool((result.get("ranker") or {}).get("activated"))
if (result.get("activated") or ranker_on) and self.on_done:
self.on_done()
except Exception:
log.exception("training failed")
@@ -0,0 +1,197 @@
"""Ранжировщик волны v2 — LightGBM lambdarank на показах всех пользователей (docs/recommendations.md#6.3).
Признаки кандидата пишутся в момент показа (rec_impression_features), метка — исход показа:
3 — лайк, 2 — доиграл, 0 — скип; ранний скип и дизлайк — 0 с весом ×2. Группа — сессия волны.
Новая модель включается, только если её NDCG@10 на отложенной неделе не хуже порядка по оценке v1 и не хуже
активной модели более чем на 2%. Пока размеченных показов меньше MIN_ROWS, работает v1.
"""
from __future__ import annotations
import datetime as dt
import json
import logging
import math
import threading
from collections import defaultdict
from typing import Any
import lightgbm as lgb
import numpy as np
import psycopg
from numpy.typing import NDArray
from pipu_rec.wave.engine import FEATURE_NAMES, FEATURES_VERSION
log = logging.getLogger(__name__)
MIN_ROWS = 20_000
HOLDOUT_DAYS = 7
MAX_REGRESSION = 0.02
LABEL = {"like": 3, "complete": 2, "skip": 0, "skip_early": 0, "dislike": 0}
WEIGHT = {"skip_early": 2.0, "dislike": 2.0}
V1_COLUMN = FEATURE_NAMES.index("v1_score")
Row = tuple[str, list[float], str, float] # сессия, признаки, исход, время показа
def load_rows(conn: psycopg.Connection[Any]) -> list[Row]:
rows = conn.execute(
"""
SELECT f.wave_session_id::text, f.features, wi.outcome, extract(epoch FROM f.served_at)::float8
FROM rec_impression_features f
JOIN wave_impressions wi ON wi.wave_session_id = f.wave_session_id AND wi.track_id = f.track_id
WHERE wi.outcome IS NOT NULL AND f.served_at > now() - interval '90 days'
ORDER BY f.wave_session_id, f.served_at
"""
).fetchall()
return [(s, list(x), o, float(ts)) for s, x, o, ts in rows if o in LABEL and len(x) == len(FEATURE_NAMES)]
def _groups(rows: list[Row]) -> list[list[Row]]:
by: dict[str, list[Row]] = defaultdict(list)
for r in rows:
by[r[0]].append(r)
return [g for g in by.values() if len(g) > 1]
def _ndcg(scores: NDArray[np.float64], labels: NDArray[np.float64], k: int = 10) -> float | None:
if labels.max() <= 0:
return None # в группе нечего ранжировать — в среднем не участвует
order = np.argsort(-scores)[:k]
dcg = sum((2 ** labels[i] - 1) / math.log2(r + 2) for r, i in enumerate(order))
ideal = sorted(labels, reverse=True)[:k]
idcg = sum((2**v - 1) / math.log2(r + 2) for r, v in enumerate(ideal))
return float(dcg / idcg) if idcg > 0 else None
def mean_ndcg(groups: list[list[Row]], score: Any) -> float:
vals = []
for g in groups:
x = np.array([r[1] for r in g], dtype=np.float64)
y = np.array([LABEL[r[2]] for r in g], dtype=np.float64)
v = _ndcg(np.asarray(score(x), dtype=np.float64), y)
if v is not None:
vals.append(v)
return round(float(np.mean(vals)), 4) if vals else 0.0
def train(rows: list[Row], now: float, min_rows: int = MIN_ROWS) -> tuple[lgb.Booster | None, dict[str, Any]]:
"""Обучает модель; None — данных мало (метрики объясняют почему)."""
metrics: dict[str, Any] = {"rows": len(rows), "features_version": FEATURES_VERSION}
if len(rows) < min_rows:
metrics["skipped"] = f"need {min_rows} labelled impressions"
return None, metrics
cutoff = now - HOLDOUT_DAYS * 86400
groups = _groups(rows)
train_g = [g for g in groups if max(r[3] for r in g) < cutoff]
valid_g = [g for g in groups if max(r[3] for r in g) >= cutoff]
if not train_g or not valid_g:
metrics["skipped"] = "no time split"
return None, metrics
def matrix(
gs: list[list[Row]],
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64], list[int]]:
x = np.array([r[1] for g in gs for r in g], dtype=np.float64)
y = np.array([LABEL[r[2]] for g in gs for r in g], dtype=np.int64)
w = np.array([WEIGHT.get(r[2], 1.0) for g in gs for r in g], dtype=np.float64)
return x, y, w, [len(g) for g in gs]
x, y, w, group = matrix(train_g)
params = {
"objective": "lambdarank",
"learning_rate": 0.05,
"num_leaves": 31,
"min_data_in_leaf": 20,
"verbose": -1,
}
booster = lgb.train(params, lgb.Dataset(x, y, weight=w, group=group), num_boost_round=200)
metrics.update(
{
"train_groups": len(train_g),
"valid_groups": len(valid_g),
"ndcg@10": mean_ndcg(valid_g, booster.predict),
"ndcg@10_v1": mean_ndcg(valid_g, lambda m: m[:, V1_COLUMN]),
}
)
return booster, metrics
def should_activate(metrics: dict[str, Any], current: dict[str, Any] | None) -> bool:
new = float(metrics.get("ndcg@10", 0.0))
if new < float(metrics.get("ndcg@10_v1", 0.0)):
return False
if current and current.get("ndcg@10"):
return new >= float(current["ndcg@10"]) * (1 - MAX_REGRESSION)
return True
def train_and_save(conn: psycopg.Connection[Any], now: float) -> dict[str, Any]:
"""Шаг ночного пайплайна: обучение, проверка, запись в model_versions."""
booster, metrics = train(load_rows(conn), now)
if booster is None:
return {"ranker": metrics}
version = "lgbm-" + dt.datetime.fromtimestamp(now, dt.UTC).strftime("%Y%m%d%H%M")
row = conn.execute(
"SELECT metrics FROM model_versions WHERE kind = 'ranker' AND is_active "
"ORDER BY created_at DESC LIMIT 1"
).fetchone()
active = should_activate(metrics, row[0] if row else None)
with conn.transaction():
if active:
conn.execute("UPDATE model_versions SET is_active = false WHERE kind = 'ranker' AND is_active")
conn.execute(
"INSERT INTO model_versions (id, kind, metrics, is_active, artifact) "
"VALUES (%s, 'ranker', %s, %s, %s) "
"ON CONFLICT (id) DO NOTHING",
(version, json.dumps(metrics), active, booster.model_to_string()),
)
log.info("ranker %s %s: %s", version, "activated" if active else "kept inactive", metrics)
return {"ranker": {"version": version, "activated": active, **metrics}}
class LoadedRanker:
"""Активная модель в памяти сервиса (Ranker для engine.build_batch)."""
def __init__(self, version: str, model: str) -> None:
self.version = version
self.booster = lgb.Booster(model_str=model)
def predict(self, x: NDArray[np.float64]) -> NDArray[np.float64]:
return np.asarray(self.booster.predict(x), dtype=np.float64)
class RankerHolder:
"""Загружает активный ранжировщик из model_versions; reload — после ночного обучения."""
def __init__(self, dsn: str) -> None:
self.dsn = dsn
self._ranker: LoadedRanker | None = None
self._loaded = False
self._lock = threading.Lock()
def get(self) -> LoadedRanker | None:
if not self._loaded:
self.reload()
return self._ranker
def reload(self) -> None:
with self._lock:
self._loaded = True
try:
with psycopg.connect(self.dsn) as conn:
row = conn.execute(
"SELECT id, artifact, metrics FROM model_versions "
"WHERE kind = 'ranker' AND is_active "
"AND artifact IS NOT NULL ORDER BY created_at DESC LIMIT 1"
).fetchone()
if row is None or (row[2] or {}).get("features_version") != FEATURES_VERSION:
self._ranker = None
return
self._ranker = LoadedRanker(row[0], row[1])
log.info("ranker %s loaded", row[0])
except Exception:
log.exception("ranker load failed — wave uses v1")
self._ranker = None
+161 -5
View File
@@ -2,7 +2,10 @@
from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
from typing import Protocol
import numpy as np
from numpy.typing import NDArray
@@ -93,6 +96,9 @@ class Profile:
rediscover: set[str] # любимые, не игравшие > 60 дней
exploration: float = 0.15
taste_cf: list[float] | None = None # фактор пользователя ALS
bandit: dict[str, list[float]] = field(
default_factory=dict
) # источник → [α, β] (docs/recommendations.md#6.6)
recent_likes: list[str] = field(default_factory=list) # последние лайки, новые первыми (для item_cf)
@@ -111,8 +117,60 @@ class Pick:
id: str
source: str
score: float
features: list[float] | None = None # вектор признаков в момент показа (для обучения ранжировщика)
# Источники кандидатов (one-hot в признаках ранжировщика; порядок фиксирован).
SOURCES = ["seed", "fresh", "audio_sim", "cf", "item_cf", "artist", "rediscover", "popular", "explore"]
# Признаки ранжировщика v2 (docs/recommendations.md#6.3). Порядок — часть формата модели:
# менять только вместе с FEATURES_VERSION (старые модели тогда не загружаются).
FEATURE_NAMES = [
"sim_session",
"sim_taste",
"cf",
"artist_affinity",
"popularity",
"v1_score",
"context_fit",
"is_new",
"liked_recent",
"bandit_mean",
*[f"src_{s}" for s in SOURCES],
"energy",
"bpm",
"danceability",
"valence",
"hour_sin",
"hour_cos",
"position",
"skips_in_row",
"has_activity",
"has_mood",
]
FEATURES_VERSION = 1
class Ranker(Protocol):
"""Обученный ранжировщик: оценки кандидатов по матрице признаков (строки — кандидаты)."""
version: str
def predict(self, x: NDArray[np.float64]) -> NDArray[np.float64]: ...
@dataclass
class Context:
"""Контекст показа для признаков ранжировщика."""
hour: float = field(default_factory=lambda: time.localtime().tm_hour + time.localtime().tm_min / 60)
has_activity: bool = False
has_mood: bool = False
# Вес бонуса бандита к оценке (θ − 0.5 ∈ [−0.5, 0.5]).
BANDIT_WEIGHT = 0.2
# Вклад реакции в сессионный вектор: s ← normalize(s + η·r·e).
REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5}
ETA = 0.3
@@ -176,10 +234,66 @@ def cf_scores(index: CatalogIndex, profile: Profile) -> NDArray[np.float64] | No
return np.where(index.has_cf, index.cf @ u, 0.0).astype(np.float64)
def model_version(index: CatalogIndex, profile: Profile) -> str:
def model_version(index: CatalogIndex, profile: Profile, ranker: Ranker | None = None) -> str:
if ranker is not None:
return ranker.version
return MODEL_VER + (CF_SUFFIX if cf_scores(index, profile) is not None else "")
def candidate_features(
index: CatalogIndex,
profile: Profile,
session: Session,
cand: NDArray[np.intp],
sources: dict[int, str],
sim_session: NDArray[np.float64],
sim_taste: NDArray[np.float64],
cf_score: NDArray[np.float64] | None,
artist_aff: NDArray[np.float64],
v1: NDArray[np.float64],
context_fit: NDArray[np.float64],
ctx: Context,
) -> NDArray[np.float64]:
"""Матрица признаков (кандидаты × FEATURE_NAMES)."""
m = len(cand)
x = np.zeros((m, len(FEATURE_NAMES)), dtype=np.float64)
def feat(name: str) -> NDArray[np.float64]:
v = index.features.get(name)
if v is None:
return np.full(m, -1.0)
return np.nan_to_num(v[cand], nan=-1.0)
col = {n: k for k, n in enumerate(FEATURE_NAMES)}
x[:, col["sim_session"]] = sim_session[cand]
x[:, col["sim_taste"]] = sim_taste[cand]
x[:, col["cf"]] = cf_score[cand] if cf_score is not None else 0.0
x[:, col["artist_affinity"]] = artist_aff[cand]
x[:, col["popularity"]] = index.popularity[cand]
x[:, col["v1_score"]] = v1
x[:, col["context_fit"]] = context_fit
x[:, col["is_new"]] = [index.ids[i] not in profile.played for i in cand]
x[:, col["liked_recent"]] = [index.ids[i] in profile.liked_recent for i in cand]
for j, i in enumerate(cand):
src = sources[int(i)]
ab = profile.bandit.get(src)
x[j, col["bandit_mean"]] = ab[0] / (ab[0] + ab[1]) if ab else 0.5
if src in SOURCES:
x[j, col[f"src_{src}"]] = 1.0
x[:, col["energy"]] = feat("energy")
bpm = feat("bpm")
x[:, col["bpm"]] = np.where(bpm > 0, bpm / 200.0, -1.0)
x[:, col["danceability"]] = feat("danceability")
x[:, col["valence"]] = feat("valence")
x[:, col["hour_sin"]] = math.sin(2 * math.pi * ctx.hour / 24)
x[:, col["hour_cos"]] = math.cos(2 * math.pi * ctx.hour / 24)
x[:, col["position"]] = min(len(session.served) / 50.0, 1.0)
x[:, col["skips_in_row"]] = min(session.skips_in_row / 3.0, 1.0)
x[:, col["has_activity"]] = float(ctx.has_activity)
x[:, col["has_mood"]] = float(ctx.has_mood)
return x
def _top(scores: NDArray[np.float64], mask: NDArray[np.bool_], k: int) -> NDArray[np.intp]:
idx = np.nonzero(mask)[0]
if len(idx) <= k:
@@ -197,6 +311,8 @@ def build_batch(
exclude: set[str] | None = None,
seed_ids: list[str] | None = None,
rng: np.random.Generator | None = None,
ranker: Ranker | None = None,
ctx: Context | None = None,
) -> list[Pick]:
"""Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия."""
rng = rng or np.random.default_rng()
@@ -273,7 +389,7 @@ def build_batch(
add(rng.permutation(np.nonzero(allowed)[0])[:40].tolist(), "explore")
cand = np.array(sorted(sources))
# 3. Оценка v1.
# 3. Оценка v1 (она же — признак и запасной вариант для v2).
score = (
0.35 * sim_session[cand]
+ 0.25 * sim_taste[cand]
@@ -284,8 +400,17 @@ def build_batch(
)
liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand])
score = score - 0.2 * liked_recent
# Бандит: семплирование Томпсона по источникам — удачные для пользователя источники получают бонус.
# Источник без наблюдений — нейтральные 0.5 (без шума, иначе холодный старт ранжировался бы случайно).
theta: dict[str, float] = {}
for src in set(sources.values()):
ab = profile.bandit.get(src)
theta[src] = float(rng.beta(ab[0], ab[1])) if ab else 0.5
score = score + BANDIT_WEIGHT * (np.array([theta[sources[int(i)]] for i in cand]) - 0.5)
v1 = score.copy()
# 4. Соответствие контексту; если отсеялось слишком много — порог ослабляется.
f = np.ones(len(cand))
if rules:
f = fit({k: v[cand] for k, v in index.features.items()}, rules)
# Нужен запас для разнообразия, но не больше 50 и не больше четверти пула.
@@ -294,11 +419,33 @@ def build_batch(
keep = f >= threshold
if keep.sum() >= enough or threshold == 0.0:
break
cand, score, f = cand[keep], score[keep], f[keep]
cand, score, f, v1 = cand[keep], score[keep], f[keep], v1[keep]
score = score + 0.5 * f # контекст важнее вкуса, когда выбран явно
if len(cand) == 0:
return []
# Признаки кандидатов: пишутся с показом (обучение v2) и подаются обученному ранжировщику.
ctx = ctx or Context()
f64 = np.float64
x = candidate_features(
index,
profile,
session,
cand,
sources,
np.asarray(sim_session, f64),
np.asarray(sim_taste, f64),
cf_score,
np.asarray(artist_aff, f64),
np.asarray(v1, f64),
np.asarray(f, f64),
ctx,
)
if ranker is not None:
# v2: порядок задаёт модель; в MMR нужна шкала 0..1 — берём ранг предсказания.
pred = ranker.predict(x)
score = np.argsort(np.argsort(pred)) / max(len(pred) - 1, 1)
# 5. MMR + правила: 1 трек исполнителя на пачку, не больше 2 на последние 10; доля «нового».
picks: list[Pick] = []
chosen: list[int] = []
@@ -333,7 +480,14 @@ def build_batch(
i = int(cand[best_j])
chosen.append(i)
order.remove(best_j)
picks.append(Pick(id=index.ids[i], source=sources[i], score=round(float(score[best_j]), 4)))
picks.append(
Pick(
id=index.ids[i],
source=sources[i],
score=round(float(score[best_j]), 4),
features=x[best_j].tolist(),
)
)
# Исследование: «новых» (неслышанных) меньше доли exploration — заменяем ими слабейшие выборы.
fresh_left = sorted(
@@ -348,5 +502,7 @@ def build_batch(
weakest = min(others, key=lambda k: picks[k].score)
if any(index.artist[index.pos[p.id]] == index.artist[i] for k, p in enumerate(picks) if k != weakest):
continue
picks[weakest] = Pick(id=index.ids[i], source="fresh", score=round(float(score[j]), 4))
picks[weakest] = Pick(
id=index.ids[i], source="fresh", score=round(float(score[j]), 4), features=x[j].tolist()
)
return picks
+32 -2
View File
@@ -2,18 +2,22 @@
from __future__ import annotations
import logging
from typing import Literal
import numpy as np
import psycopg
from pydantic import BaseModel, Field
from pipu_rec.train.ranker import RankerHolder
from pipu_rec.wave.contexts import Contexts, Rule
from pipu_rec.wave.engine import Pick, build_batch, model_version, taste_centroids, update_session
from pipu_rec.wave.engine import Context, Pick, build_batch, model_version, taste_centroids, update_session
from pipu_rec.wave.store import IndexHolder, SessionStore, load_profile, seed_tracks
MAX_SERVED_MEMORY = 300
log = logging.getLogger(__name__)
Outcome = Literal["complete", "skip", "skip_early", "like", "dislike"]
@@ -55,6 +59,12 @@ class WaveService:
self.index = IndexHolder(dsn)
self.sessions = SessionStore(redis_url)
self.contexts = contexts
self.ranker = RankerHolder(dsn)
def reload(self) -> None:
"""После ночного обучения: перечитать каталог (векторы ALS) и активный ранжировщик."""
self.index.refresh()
self.ranker.reload()
def rules(self, activity: str | None, mood: str | None) -> list[Rule]:
"""Занятие и настроение комбинируются — пересечение профилей; неизвестный ключ игнорируется."""
@@ -74,6 +84,7 @@ class WaveService:
update_session(
index, session, [(f.track_id, f.outcome) for f in req.feedback], taste_centroids(index, profile)
)
ranker = self.ranker.get()
picks: list[Pick] = build_batch(
index,
profile,
@@ -83,12 +94,31 @@ class WaveService:
exclude=set(req.exclude_ids),
seed_ids=seeds,
rng=np.random.default_rng(),
ranker=ranker,
ctx=Context(has_activity=bool(req.activity), has_mood=bool(req.mood)),
)
self.log_features(req.session_id, picks)
session.served = (session.served + [p.id for p in picks])[-MAX_SERVED_MEMORY:]
artists = [index.artist[index.pos[p.id]] for p in picks]
session.recent_artists = (session.recent_artists + artists)[-10:]
self.sessions.put(req.session_id, session)
return BatchResponse(
tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks],
model_ver=model_version(index, profile),
model_ver=model_version(index, profile, ranker),
)
def log_features(self, session_id: str, picks: list[Pick]) -> None:
"""Признаки показанных треков — обучающие данные ранжировщика v2. Сбой записи не ломает волну."""
rows = [(session_id, p.id, p.features) for p in picks if p.features is not None]
if not rows:
return
try:
with psycopg.connect(self.dsn, autocommit=True) as conn, conn.cursor() as cur:
cur.executemany(
"INSERT INTO rec_impression_features (wave_session_id, track_id, features) "
"VALUES (%s, %s, %s) "
"ON CONFLICT DO NOTHING",
rows,
)
except psycopg.Error as e:
log.warning("impression features not saved: %s", e)
+36 -1
View File
@@ -156,10 +156,14 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
)
}
row = conn.execute(
"SELECT exploration, taste_cf::text FROM user_profiles_rec WHERE user_id = %s", (user_id,)
"SELECT exploration, taste_cf::text, bandit, onboarding FROM user_profiles_rec WHERE user_id = %s",
(user_id,),
).fetchone()
if row and row[3] and len(scores) < ONBOARDING_UNTIL:
apply_onboarding(conn, scores, row[3])
exploration = float(row[0]) if row else 0.15
taste_cf = parse_vector(row[1]) if row else None
bandit = {str(k): [float(x) for x in v] for k, v in (row[2] or {}).items()} if row else {}
if len(played) < 30:
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
day = 86400
@@ -173,10 +177,41 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day},
exploration=exploration,
taste_cf=taste_cf.tolist() if taste_cf is not None else None,
bandit=bandit,
recent_likes=recent_likes,
)
# Пока своих оценок меньше, выбор онбординга задаёт начальный вкус (docs/recommendations.md#7).
ONBOARDING_UNTIL = 30
ONBOARDING_LIMIT = 300
def apply_onboarding(conn: psycopg.Connection[Any], scores: dict[str, float], choice: dict[str, Any]) -> None:
"""Псевдооценки: треки выбранных исполнителей +1, выбранных жанров +0.5 (до ONBOARDING_LIMIT треков).
Собственные оценки не перезаписываются; в «слышанное» эти треки не попадают."""
artists = [str(a) for a in choice.get("artist_ids") or []]
genres = [str(g).lower() for g in choice.get("genres") or []]
pseudo: dict[str, float] = {}
if artists:
for (tid,) in conn.execute(
"SELECT DISTINCT track_id::text FROM track_artists WHERE artist_id::text = ANY(%s) LIMIT %s",
(artists, ONBOARDING_LIMIT),
):
pseudo[tid] = pseudo.get(tid, 0.0) + 1.0
if genres:
for (tid,) in conn.execute(
"""
SELECT DISTINCT tg.track_id::text FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE lower(g.name) = ANY(%s) LIMIT %s
""",
(genres, ONBOARDING_LIMIT),
):
pseudo[tid] = pseudo.get(tid, 0.0) + 0.5
for tid, v in pseudo.items():
scores.setdefault(tid, v)
def seed_tracks(conn: psycopg.Connection[Any], seed_type: str, seed_id: str) -> list[str]:
"""Треки, от которых запущена волна: трек, альбом или исполнитель."""
queries = {
+1
View File
@@ -11,6 +11,7 @@ dependencies = [
"redis>=6.2,<7",
"numpy>=2.2,<3",
"pyyaml>=6.0,<7",
"lightgbm>=4.5,<5",
]
[project.scripts]
@@ -3,6 +3,7 @@
Локально: TEST_DATABASE_URL=postgresql://… uv run pytest tests/test_db_integration.py
"""
import json
import os
import time
import uuid
@@ -139,3 +140,44 @@ def test_training_and_wave_queries(world: dict[str, Any]) -> None:
assert active is not None and active[0] == 1
picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0))
assert len(picks) == 5
def test_onboarding_seeds_taste(world: dict[str, Any]) -> None:
user = uid()
with psycopg.connect(DSN, autocommit=True) as conn:
conn.execute(
"INSERT INTO users (id, username, password_hash) VALUES (%s, %s, 'x')", (user, f"new-{user[:6]}")
)
artist = conn.execute(
"SELECT artist_id::text FROM track_artists WHERE track_id = %s", (world["tracks"][0],)
).fetchone()
assert artist is not None
conn.execute(
"INSERT INTO user_profiles_rec (user_id, onboarding) VALUES (%s, %s)",
(user, json.dumps({"artist_ids": [artist[0]], "genres": []})),
)
profile = load_profile(conn, user)
assert len(profile.scores) >= 10, "chosen artist's tracks become the initial taste"
assert not profile.played
def test_impression_features_roundtrip(world: dict[str, Any]) -> None:
from pipu_rec.train.ranker import load_rows, train_and_save
from pipu_rec.wave.engine import FEATURE_NAMES
session, user, track = uid(), world["users"][0], world["tracks"][0]
with psycopg.connect(DSN, autocommit=True) as conn:
conn.execute("INSERT INTO wave_sessions (id, user_id) VALUES (%s, %s)", (session, user))
conn.execute(
"INSERT INTO wave_impressions (wave_session_id, position, track_id, candidate_source, outcome) "
"VALUES (%s, 0, %s, 'audio_sim', 'complete')",
(session, track),
)
conn.execute(
"INSERT INTO rec_impression_features (wave_session_id, track_id, features) VALUES (%s, %s, %s)",
(session, track, [0.5] * len(FEATURE_NAMES)),
)
rows = load_rows(conn)
assert any(r[0] == session and r[2] == "complete" for r in rows)
result = train_and_save(conn, time.time())
assert "skipped" in result["ranker"], "too few impressions — v1 stays"
+93
View File
@@ -116,3 +116,96 @@ def test_cf_vectors_are_padded_to_column_size() -> None:
v = np.ones((3, 5), dtype=np.float32)
assert pad(v).shape == (3, CF_DIM)
assert pad(np.ones((2, 80), dtype=np.float32)).shape == (2, CF_DIM)
def test_bandit_params_and_exploration() -> None:
from pipu_rec.train.bandit import compute, exploration
now = 100 * DAY
rows = [("u", "fresh", "complete", now)] * 8 + [("u", "fresh", "skip", now)] * 2
rows += [("u", "audio_sim", "complete", now)] * 2 + [("u", "audio_sim", "skip_early", now)] * 8
rows += [("u", "cf", "complete", now - 69 * DAY)] # затухание: ~0.5
p = compute(rows, now)["u"]
assert p["fresh"] == [9.0, 3.0]
assert p["audio_sim"] == [3.0, 9.0]
assert 1.4 < p["cf"][0] < 1.6
assert exploration(p) > 0.15 # «новое» заходит лучше остального — исследования больше
assert exploration({"fresh": [1.0, 9.0], "audio_sim": [9.0, 1.0]}) == 0.05
def test_bandit_favours_successful_source() -> None:
n = 60
ids = [f"t{i}" for i in range(n)]
index = CatalogIndex.build(
ids=ids, artist=[f"a{i}" for i in range(n)], raw_emb=[None] * n, features={}, plays=[0] * n
)
good = Profile(
scores={},
disliked=set(),
disliked_artists=set(),
recent=set(),
played=set(),
liked_recent=set(),
rediscover=set(),
exploration=0.0,
bandit={"popular": [50.0, 1.0], "explore": [1.0, 50.0]},
)
picks = build_batch(index, good, Session(), [], n=5, rng=np.random.default_rng(1))
assert sum(p.source == "popular" for p in picks) >= 4
def test_ranker_learns_what_v1_misses() -> None:
from pipu_rec.train.ranker import V1_COLUMN, should_activate, train
from pipu_rec.wave.engine import FEATURE_NAMES
rng = np.random.default_rng(0)
now = 200 * DAY
rows = []
col = FEATURE_NAMES.index("sim_session")
for g in range(400):
ts = now - (1 if g >= 320 else 30) * DAY # последние сессии — отложенная неделя
for _ in range(8):
x = rng.random(len(FEATURE_NAMES)).tolist()
# Исход определяется sim_session, а оценка v1 в признаках — случайный шум.
outcome = "like" if x[col] > 0.85 else "complete" if x[col] > 0.5 else "skip_early"
x[V1_COLUMN] = float(rng.random())
rows.append((f"s{g}", x, outcome, ts))
booster, m = train(rows, now, min_rows=100)
assert booster is not None, m
assert m["ndcg@10"] > m["ndcg@10_v1"] + 0.1, m
assert should_activate(m, None)
assert not should_activate({"ndcg@10": 0.5, "ndcg@10_v1": 0.6}, None)
assert not should_activate({"ndcg@10": 0.7, "ndcg@10_v1": 0.6}, {"ndcg@10": 0.8})
_, few = train(rows[:50], now)
assert "skipped" in few
def test_engine_uses_ranker_and_logs_features() -> None:
from pipu_rec.wave.engine import FEATURE_NAMES
n = 40
ids = [f"t{i}" for i in range(n)]
index = CatalogIndex.build(
ids=ids, artist=[f"a{i}" for i in range(n)], raw_emb=[None] * n, features={}, plays=[0] * n
)
profile = Profile(
scores={},
disliked=set(),
disliked_artists=set(),
recent=set(),
played=set(),
liked_recent=set(),
rediscover=set(),
exploration=0.0,
)
class LastFirst:
version = "fake-ranker"
def predict(self, x: np.ndarray) -> np.ndarray:
return -x[:, FEATURE_NAMES.index("popularity")] + np.arange(len(x)) * 1e-3
picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0), ranker=LastFirst())
assert len(picks) == 5
assert all(p.features is not None and len(p.features) == len(FEATURE_NAMES) for p in picks)
assert model_version(index, profile, LastFirst()) == "fake-ranker"
+50
View File
@@ -176,6 +176,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" },
]
[[package]]
name = "lightgbm"
version = "4.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "narwhals" },
{ name = "numpy" },
{ name = "scipy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/8e/4db5e29290d7e619c307fdb8dab0a0514090af2ce3ec483050e024ec6126/lightgbm-4.7.0.tar.gz", hash = "sha256:f8e20f682c9aabd000bcf4a7ed8aa6f473c1adfecccae34ec24e823d156f4af0", size = 1792896, upload-time = "2026-07-18T21:00:56.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/05/7213965863cba1ed0150ad045bceed6276a1afaaaedbaeff4699ec4f0ccb/lightgbm-4.7.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:dfc1cfe8e760387be1e7ba7a214688be21fdff96e4ed9749188f83e1877c2477", size = 1877851, upload-time = "2026-07-18T21:00:35.225Z" },
{ url = "https://files.pythonhosted.org/packages/b2/86/f4fe714f2e0bf3941705a20d7f6849dc476276d71236e82ea6b0d6539b86/lightgbm-4.7.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:129535462686f274df179133643118c5c5c5667167fe6c3a28d955f0b3c8e868", size = 1498914, upload-time = "2026-07-18T21:00:36.549Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/b29580948b92e8c2f84dea70118ac702ff067dc52ec4ffb5d73c953536a5/lightgbm-4.7.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4529acec5c6fefe4768302a529707d0ead90f6a6f42df694b856212e09695b8", size = 3349492, upload-time = "2026-07-18T21:00:37.943Z" },
{ url = "https://files.pythonhosted.org/packages/15/eb/837ea3b40cc36e22eeebb9785c01e42b2c255d033eea1d2d9ee8e2540e55/lightgbm-4.7.0-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d23e922acd891e77212e4d0fbcee9ba973c96dee479491341d05ba595357ebb7", size = 3476028, upload-time = "2026-07-18T21:00:39.331Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0b/c5c17d862b12ce292f24cd85d40f2f8f8981668fbdbd43fdc2625eccbc79/lightgbm-4.7.0-py3-none-win_amd64.whl", hash = "sha256:f42d1e5b32b6f170e606d7c689c6165671da98d7bf37f1addec2623efc8740c9", size = 1360833, upload-time = "2026-07-18T21:00:40.865Z" },
]
[[package]]
name = "mypy"
version = "1.20.2"
@@ -207,6 +225,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "narwhals"
version = "2.27.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/21/f64d6b2dbea7bf3f8c38cdc786dcc6ef012ca3d173ad208c782c9a7bedf6/narwhals-2.27.1.tar.gz", hash = "sha256:aed93076a3ea42d9c32c88e4eb5ea422a21937011cbe1f480f9572a523c82094", size = 735013, upload-time = "2026-10-10T06:52:18.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl", hash = "sha256:d057df13f5852b8e157596e82eb5e955fad267425df5e420e0ee9863da483b31", size = 483211, upload-time = "2026-10-10T06:52:16.32Z" },
]
[[package]]
name = "numpy"
version = "2.5.4"
@@ -262,6 +289,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "lightgbm" },
{ name = "numpy" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
@@ -282,6 +310,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "lightgbm", specifier = ">=4.5,<5" },
{ name = "numpy", specifier = ">=2.2,<3" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" },
{ name = "pydantic-settings", specifier = ">=2.9,<3" },
@@ -490,6 +519,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" },
]
[[package]]
name = "scipy"
version = "1.18.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" },
{ url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" },
{ url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" },
{ url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" },
{ url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" },
{ url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" },
{ url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" },
{ url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" },
]
[[package]]
name = "starlette"
version = "1.9.0"