Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e250837de9 | ||
|
|
58bd14ef38 | ||
|
|
fb07f962eb | ||
|
|
ff0aafdb1e | ||
|
|
f7722e28ad | ||
|
|
ed0302d90f | ||
|
|
85fceaa686 | ||
|
|
6ad49fe0e3 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -90,6 +90,7 @@ class Controller {
|
||||
private lastSave = 0;
|
||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||
private waveLoading = false;
|
||||
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
||||
private setupDone: Promise<void> | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private state: PlayerState = {
|
||||
@@ -189,6 +190,14 @@ class Controller {
|
||||
if (!a) return;
|
||||
this.attempt = null;
|
||||
const pos = Math.round(lastPositionS * 1000);
|
||||
if (this.neutralSwitch) {
|
||||
this.neutralSwitch = false;
|
||||
if (a.playedMs >= EARLY_SKIP_MS) {
|
||||
this.events?.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||
void this.events?.flush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const kind = ended ? 'play_end' : a.outcome(pos);
|
||||
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||
void this.events?.flush();
|
||||
@@ -316,6 +325,13 @@ class Controller {
|
||||
);
|
||||
await this.replaceUpcoming(res.tracks);
|
||||
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
||||
if (res.tracks.some(playable)) {
|
||||
this.neutralSwitch = true;
|
||||
await TrackPlayer.skipToNext().catch(() => {
|
||||
this.neutralSwitch = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private leaveWave() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -186,7 +186,10 @@ export class Player {
|
||||
void this.load(true);
|
||||
}
|
||||
|
||||
/** Сменить занятие / настроение на лету: текущий трек доигрывает, дальше — новая пачка. */
|
||||
/**
|
||||
* Сменить занятие / настроение на лету: сразу играет первый трек новой пачки (docs/recommendations.md#6.7).
|
||||
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
|
||||
*/
|
||||
async setWaveContext(activity: string | null, mood: string | null) {
|
||||
const wave = this.state.wave;
|
||||
if (!wave) return this.startWave(activity, mood);
|
||||
@@ -197,10 +200,17 @@ export class Player {
|
||||
body: { activity, mood },
|
||||
}),
|
||||
);
|
||||
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
|
||||
this.queue.dropUpcoming();
|
||||
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url));
|
||||
this.queue.append(fresh);
|
||||
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||
this.syncQueue();
|
||||
if (fresh.length && this.queue.next()) {
|
||||
this.finishAttempt('skip', true);
|
||||
this.syncQueue();
|
||||
void this.load(true);
|
||||
} else {
|
||||
this.syncQueue();
|
||||
}
|
||||
}
|
||||
|
||||
stopWave() {
|
||||
@@ -395,11 +405,19 @@ export class Player {
|
||||
}
|
||||
|
||||
/** Завершает текущую попытку: событие с реально прослушанным временем. */
|
||||
private finishAttempt(type: 'play_end' | 'skip') {
|
||||
/** neutral — трек прерван сменой контекста волны: не отрицательный сигнал и без короткого «скипа» в событиях. */
|
||||
private finishAttempt(type: 'play_end' | 'skip', neutral = false) {
|
||||
const a = this.attempt;
|
||||
if (!a) return;
|
||||
this.attempt = null;
|
||||
const pos = Math.round(this.audio.currentTime * 1000);
|
||||
if (neutral) {
|
||||
if (a.playedMs >= EARLY_SKIP_MS) {
|
||||
this.events.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||
void this.events.flush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
|
||||
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||
void this.events.flush();
|
||||
|
||||
+1
-1
@@ -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 — иначе тяжёлая сборка может подвесить весь хост.
|
||||
|
||||
@@ -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-}`, необязательная группа → `[ ]`,
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}} исполнителя"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Generated
+50
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user