Named taste profiles with anonymous guest support #3

Open
opened 2026-02-22 20:13:09 +00:00 by antialias · 0 comments
Owner

Problem

All listen events and taste profiles are global — there's a single "default" taste profile built from every listen event in the system. This means:

  • If multiple household members listen to music, the taste profile becomes a muddled average of everyone's preferences
  • There's no way for a specific person to get recommendations tuned to their taste
  • Guests can't get any recommendations without polluting the household profile

Goal

Support named taste profiles so household members get personalized recommendations, while keeping the system simple and usable for guests who don't have (or want) a profile.

Design Principles

  • No auth — profiles are just named buckets, not user accounts. This is a household system, not a multi-tenant SaaS.
  • Backward compatible — all existing endpoints work identically without a profile parameter (uses "default")
  • Guest-friendly — guests use vibe-only playlists (already works via cold start) or a shared "default" profile
  • Speaker inference — optionally map speakers to profiles so listen events auto-attribute based on which room is playing

Data Model Changes

New: profiles table

Column Type Notes
id BigInteger PK
name Text Unique, e.g. "antialias", "default"
display_name Text Optional friendly name
created_at DateTime

Seed with a "default" profile on migration.

Modify: listen_events

Add nullable profile_id column (FK → profiles.id). Existing rows keep profile_id = NULL, treated as belonging to "default" profile.

Modify: taste_profiles

Add profile_id column (FK → profiles.id). Replace the name unique constraint — now keyed by profile_id instead. The existing "default" taste profile gets linked to the "default" profile.

New: speaker_profile_mappings table

Column Type Notes
id BigInteger PK
speaker_name Text Unique, e.g. "Study speaker"
profile_id BigInteger FK → profiles.id

Optional. When a listen event comes in with a speaker_name that matches a mapping, auto-set profile_id. No mapping = stays on "default".

API Changes

All recommendation/playlist endpoints: add profile query/body param

  • GET /api/recommendations?profile=antialias&vibe=chill
  • POST /api/playlists/generate body: {"profile": "antialias", "vibe": "chill ambient", ...}
  • If profile is omitted → use "default" (backward compatible)
  • If profile doesn't exist → 404 or create on-the-fly (TBD)

Webhook: attribute listen events to profiles

POST /api/history/webhook payload gains optional profile field:

{"title": "Song", "artist": "Artist", "profile": "antialias"}

Resolution order:

  1. Explicit profile in payload → use it
  2. speaker_name matches a speaker mapping → use mapped profile
  3. Neither → attribute to "default"

New admin endpoints

  • GET /api/profiles — list all profiles
  • POST /api/profiles — create a profile ({"name": "antialias", "display_name": "Me"})
  • DELETE /api/profiles/{name} — delete a profile (reassign events to "default"?)
  • GET /api/profiles/{name} — get profile details + stats (event count, track count, last listen)
  • POST /api/profiles/{name}/build-taste — rebuild taste profile for specific person
  • PUT /api/profiles/{name}/speakers — set speaker mappings for a profile

Taste profile building

build_taste_profile() changes:

  • Accept profile_id parameter
  • Filter ListenEvent by profile_id (or profile_id IS NULL for "default")
  • Build weighted average from only that profile's events
  • Store result linked to the profile

On webhook: rebuild only the affected profile's taste (not all profiles).

Guest / Anonymous Flow

Guests don't need a profile at all:

  1. Vibe-only playlist: {"vibe": "chill dinner party music", "total_tracks": 15, "auto_play": true} — no profile param, works via CLAP text embedding against the full track catalog (cold start path already implemented)
  2. Default profile: If no vibe either, falls back to "default" profile (household average — reasonable for guests)
  3. Listen events from guests: Go to "default" unless a speaker mapping overrides

Migration Strategy

Alembic migration (003)

  1. Create profiles table, seed "default" row
  2. Create speaker_profile_mappings table
  3. Add profile_id (nullable) to listen_events
  4. Add profile_id to taste_profiles, link existing "default" row
  5. Backfill: existing listen events stay profile_id = NULL (treated as "default")

Data backfill (optional, manual)

If desired, could retroactively assign historical events by speaker_name:

UPDATE listen_events SET profile_id = (SELECT id FROM profiles WHERE name = 'antialias')
WHERE speaker_name IN ('Study speaker', 'Master bathroom speaker');

But this is a manual admin decision, not automated.

OpenClaw Integration

Update the haunt-fm skill doc so OpenClaw can:

  • Ask "who's listening?" or infer from context
  • Pass "profile": "antialias" in API calls
  • Default to no profile for unknown/guest users
  • Example: "Play chill music for me in the study" → {"profile": "antialias", "vibe": "chill ambient", "speaker_entity": "media_player.study_speaker_2", "auto_play": true}

Implementation Order

  1. Models + migration — profiles table, speaker_profile_mappings, alter listen_events and taste_profiles
  2. Profile CRUD API — create/list/delete profiles, speaker mappings
  3. Taste profile service — parametrize by profile_id, filter listen events
  4. Webhook attribution — resolve profile from payload or speaker mapping
  5. Recommendation/playlist endpoints — accept profile param, pass through
  6. Docs + OpenClaw skill update

What does NOT change

  • Track discovery pipeline (Last.fm, iTunes, CLAP embedding) — shared across all profiles
  • The embedding worker — embeddings are per-track, not per-profile
  • The HNSW index — same vector index queried by different profile embeddings
  • Vibe feature — works independently of profiles
  • No authentication or authorization — this is a trusted household system
## Problem All listen events and taste profiles are global — there's a single "default" taste profile built from every listen event in the system. This means: - If multiple household members listen to music, the taste profile becomes a muddled average of everyone's preferences - There's no way for a specific person to get recommendations tuned to their taste - Guests can't get any recommendations without polluting the household profile ## Goal Support **named taste profiles** so household members get personalized recommendations, while keeping the system simple and usable for guests who don't have (or want) a profile. ## Design Principles - **No auth** — profiles are just named buckets, not user accounts. This is a household system, not a multi-tenant SaaS. - **Backward compatible** — all existing endpoints work identically without a `profile` parameter (uses "default") - **Guest-friendly** — guests use vibe-only playlists (already works via cold start) or a shared "default" profile - **Speaker inference** — optionally map speakers to profiles so listen events auto-attribute based on which room is playing ## Data Model Changes ### New: `profiles` table | Column | Type | Notes | |--------|------|-------| | `id` | BigInteger | PK | | `name` | Text | Unique, e.g. "antialias", "default" | | `display_name` | Text | Optional friendly name | | `created_at` | DateTime | | Seed with a "default" profile on migration. ### Modify: `listen_events` Add nullable `profile_id` column (FK → profiles.id). Existing rows keep `profile_id = NULL`, treated as belonging to "default" profile. ### Modify: `taste_profiles` Add `profile_id` column (FK → profiles.id). Replace the `name` unique constraint — now keyed by profile_id instead. The existing "default" taste profile gets linked to the "default" profile. ### New: `speaker_profile_mappings` table | Column | Type | Notes | |--------|------|-------| | `id` | BigInteger | PK | | `speaker_name` | Text | Unique, e.g. "Study speaker" | | `profile_id` | BigInteger | FK → profiles.id | Optional. When a listen event comes in with a `speaker_name` that matches a mapping, auto-set `profile_id`. No mapping = stays on "default". ## API Changes ### All recommendation/playlist endpoints: add `profile` query/body param - `GET /api/recommendations?profile=antialias&vibe=chill` - `POST /api/playlists/generate` body: `{"profile": "antialias", "vibe": "chill ambient", ...}` - If `profile` is omitted → use "default" (backward compatible) - If `profile` doesn't exist → 404 or create on-the-fly (TBD) ### Webhook: attribute listen events to profiles `POST /api/history/webhook` payload gains optional `profile` field: ```json {"title": "Song", "artist": "Artist", "profile": "antialias"} ``` Resolution order: 1. Explicit `profile` in payload → use it 2. `speaker_name` matches a speaker mapping → use mapped profile 3. Neither → attribute to "default" ### New admin endpoints - `GET /api/profiles` — list all profiles - `POST /api/profiles` — create a profile (`{"name": "antialias", "display_name": "Me"}`) - `DELETE /api/profiles/{name}` — delete a profile (reassign events to "default"?) - `GET /api/profiles/{name}` — get profile details + stats (event count, track count, last listen) - `POST /api/profiles/{name}/build-taste` — rebuild taste profile for specific person - `PUT /api/profiles/{name}/speakers` — set speaker mappings for a profile ### Taste profile building `build_taste_profile()` changes: - Accept `profile_id` parameter - Filter `ListenEvent` by `profile_id` (or `profile_id IS NULL` for "default") - Build weighted average from only that profile's events - Store result linked to the profile On webhook: rebuild only the affected profile's taste (not all profiles). ## Guest / Anonymous Flow Guests don't need a profile at all: 1. **Vibe-only playlist**: `{"vibe": "chill dinner party music", "total_tracks": 15, "auto_play": true}` — no `profile` param, works via CLAP text embedding against the full track catalog (cold start path already implemented) 2. **Default profile**: If no vibe either, falls back to "default" profile (household average — reasonable for guests) 3. **Listen events from guests**: Go to "default" unless a speaker mapping overrides ## Migration Strategy ### Alembic migration (003) 1. Create `profiles` table, seed "default" row 2. Create `speaker_profile_mappings` table 3. Add `profile_id` (nullable) to `listen_events` 4. Add `profile_id` to `taste_profiles`, link existing "default" row 5. Backfill: existing listen events stay `profile_id = NULL` (treated as "default") ### Data backfill (optional, manual) If desired, could retroactively assign historical events by speaker_name: ```sql UPDATE listen_events SET profile_id = (SELECT id FROM profiles WHERE name = 'antialias') WHERE speaker_name IN ('Study speaker', 'Master bathroom speaker'); ``` But this is a manual admin decision, not automated. ## OpenClaw Integration Update the haunt-fm skill doc so OpenClaw can: - Ask "who's listening?" or infer from context - Pass `"profile": "antialias"` in API calls - Default to no profile for unknown/guest users - Example: "Play chill music for me in the study" → `{"profile": "antialias", "vibe": "chill ambient", "speaker_entity": "media_player.study_speaker_2", "auto_play": true}` ## Implementation Order 1. **Models + migration** — profiles table, speaker_profile_mappings, alter listen_events and taste_profiles 2. **Profile CRUD API** — create/list/delete profiles, speaker mappings 3. **Taste profile service** — parametrize by profile_id, filter listen events 4. **Webhook attribution** — resolve profile from payload or speaker mapping 5. **Recommendation/playlist endpoints** — accept profile param, pass through 6. **Docs + OpenClaw skill update** ## What does NOT change - Track discovery pipeline (Last.fm, iTunes, CLAP embedding) — shared across all profiles - The embedding worker — embeddings are per-track, not per-profile - The HNSW index — same vector index queried by different profile embeddings - Vibe feature — works independently of profiles - No authentication or authorization — this is a trusted household system
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: antialias/haunt-fm#3