Add vibe-contextual feedback system with dashboard observability

Adds feedback API endpoints that record up/down/skip signals tied to
vibe context (CLAP embeddings + text). Dashboard now shows Feedback
Activity (recent events with signal counts) and Vibe Influence (how
the same track gets rated differently across vibes).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 07:34:00 -06:00
parent ef61e275b2
commit 31f13b1efb
12 changed files with 549 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ from haunt_fm.models.base import Base
# Import all models so they register with Base.metadata
from haunt_fm.models.track import ( # noqa: F401
FeedbackEvent,
ListenEvent,
Playlist,
PlaylistTrack,

View File

@@ -0,0 +1,44 @@
"""Add feedback_events table and vibe_embedding to playlists
Revision ID: 004
Revises: 003
Create Date: 2026-02-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add vibe_embedding to playlists (nullable — only set for vibe playlists)
op.add_column("playlists", sa.Column("vibe_embedding", Vector(512), nullable=True))
# Create feedback_events table
op.create_table(
"feedback_events",
sa.Column("id", sa.BigInteger, primary_key=True),
sa.Column("playlist_id", sa.BigInteger, sa.ForeignKey("playlists.id"), nullable=False),
sa.Column("track_id", sa.BigInteger, sa.ForeignKey("tracks.id"), nullable=False),
sa.Column("vibe_embedding", Vector(512), nullable=False),
sa.Column("vibe_text", sa.Text, nullable=True),
sa.Column("signal", sa.Text, nullable=False),
sa.Column("signal_weight", sa.REAL, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# B-tree index on track_id (primary query pattern: fetch events by track)
op.create_index("ix_feedback_events_track_id", "feedback_events", ["track_id"])
def downgrade() -> None:
op.drop_index("ix_feedback_events_track_id", table_name="feedback_events")
op.drop_table("feedback_events")
op.drop_column("playlists", "vibe_embedding")

View File

@@ -0,0 +1,126 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from haunt_fm.db import get_session
from haunt_fm.models.track import FeedbackEvent, Track
from haunt_fm.services.feedback import compute_contextual_score, record_feedback
router = APIRouter(prefix="/api/feedback")
class FeedbackRequest(BaseModel):
playlist_id: int
track_id: int
signal: str
class ScoreRequest(BaseModel):
track_id: int
vibe_text: str
@router.post("")
async def submit_feedback(req: FeedbackRequest, session: AsyncSession = Depends(get_session)):
"""Submit feedback for a track in the context of a playlist's vibe."""
try:
event = await record_feedback(session, req.playlist_id, req.track_id, req.signal)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"id": event.id,
"playlist_id": event.playlist_id,
"track_id": event.track_id,
"signal": event.signal,
"signal_weight": event.signal_weight,
"vibe_text": event.vibe_text,
"created_at": event.created_at.isoformat(),
}
@router.post("/score")
async def get_score(req: ScoreRequest, session: AsyncSession = Depends(get_session)):
"""Compute the contextual feedback score for a track given a vibe description."""
from haunt_fm.services.embedding import embed_text, is_model_loaded, load_model
if not is_model_loaded():
load_model()
vibe_embedding = embed_text(req.vibe_text)
result = await session.execute(
select(FeedbackEvent).where(FeedbackEvent.track_id == req.track_id)
)
events = list(result.scalars().all())
score = compute_contextual_score(events, vibe_embedding)
# Build breakdown of contributing events
import numpy as np
breakdown = []
for event in events:
event_emb = np.array(event.vibe_embedding, dtype=np.float32)
event_norm = np.linalg.norm(event_emb)
vibe_norm = np.linalg.norm(vibe_embedding)
if event_norm > 0 and vibe_norm > 0:
cos_sim = float(np.dot(event_emb / event_norm, vibe_embedding / vibe_norm))
else:
cos_sim = 0.0
breakdown.append({
"id": event.id,
"signal": event.signal,
"signal_weight": event.signal_weight,
"vibe_text": event.vibe_text,
"cosine_similarity": round(cos_sim, 4),
"created_at": event.created_at.isoformat(),
})
return {
"track_id": req.track_id,
"vibe_text": req.vibe_text,
"contextual_score": round(score, 4),
"event_count": len(events),
"breakdown": breakdown,
}
@router.get("/history")
async def get_history(
limit: int = Query(default=50, ge=1, le=200),
track_id: int | None = Query(default=None),
session: AsyncSession = Depends(get_session),
):
"""Get recent feedback events, optionally filtered by track."""
query = (
select(FeedbackEvent)
.options(joinedload(FeedbackEvent.track))
.order_by(FeedbackEvent.created_at.desc())
.limit(limit)
)
if track_id is not None:
query = query.where(FeedbackEvent.track_id == track_id)
result = await session.execute(query)
events = result.scalars().unique().all()
return {
"events": [
{
"id": e.id,
"playlist_id": e.playlist_id,
"track_id": e.track_id,
"artist": e.track.artist,
"title": e.track.title,
"signal": e.signal,
"signal_weight": e.signal_weight,
"vibe_text": e.vibe_text,
"created_at": e.created_at.isoformat(),
}
for e in events
],
"count": len(events),
}

View File

@@ -58,6 +58,7 @@ async def generate(req: GenerateRequest, session: AsyncSession = Depends(get_ses
track_list = [
{
"position": pt.position,
"track_id": t.id,
"artist": t.artist,
"title": t.title,
"album": t.album,

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from haunt_fm.db import get_session
from haunt_fm.services.feedback import apply_feedback_adjustments
from haunt_fm.services.recommender import get_recommendations
router = APIRouter(prefix="/api")
@@ -31,4 +32,8 @@ async def recommendations(
profile_name=profile or "default",
vibe_embedding=vibe_embedding, alpha=effective_alpha,
)
# Apply feedback adjustments (re-ranks based on contextual feedback)
results = await apply_feedback_adjustments(session, results, vibe_embedding)
return {"recommendations": results, "count": len(results), "vibe": vibe, "alpha": effective_alpha, "profile": profile or "default"}

View File

@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from haunt_fm.api.status import status as get_status_data
from haunt_fm.db import get_session
from haunt_fm.models.track import (
FeedbackEvent,
ListenEvent,
Playlist,
Profile,
@@ -120,6 +121,102 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess
"updated_at": tp.updated_at,
}
# Feedback summary stats
feedback_total = (
await session.execute(select(func.count(FeedbackEvent.id)))
).scalar() or 0
feedback_by_signal: dict[str, int] = {}
if feedback_total > 0:
signal_rows = (
await session.execute(
select(FeedbackEvent.signal, func.count(FeedbackEvent.id))
.group_by(FeedbackEvent.signal)
)
).all()
feedback_by_signal = {signal: count for signal, count in signal_rows}
feedback_distinct_tracks = 0
if feedback_total > 0:
feedback_distinct_tracks = (
await session.execute(
select(func.count(func.distinct(FeedbackEvent.track_id)))
)
).scalar() or 0
feedback_summary = {
"total": feedback_total,
"up": feedback_by_signal.get("up", 0),
"down": feedback_by_signal.get("down", 0),
"skip": feedback_by_signal.get("skip", 0),
"tracks": feedback_distinct_tracks,
}
# Recent feedback events (last 15)
recent_feedback: list[dict] = []
if feedback_total > 0:
feedback_rows = (
await session.execute(
select(FeedbackEvent, Track)
.join(Track, FeedbackEvent.track_id == Track.id)
.order_by(FeedbackEvent.created_at.desc())
.limit(15)
)
).all()
recent_feedback = [
{
"signal": event.signal,
"signal_weight": event.signal_weight,
"title": track.title,
"artist": track.artist,
"vibe_text": event.vibe_text or "no vibe",
"created_at": event.created_at,
}
for event, track in feedback_rows
]
# Vibe influence data — top 10 tracks by feedback count
vibe_influence: list[dict] = []
if feedback_total > 0:
top_track_ids_result = (
await session.execute(
select(FeedbackEvent.track_id, func.count(FeedbackEvent.id).label("cnt"))
.group_by(FeedbackEvent.track_id)
.order_by(func.count(FeedbackEvent.id).desc())
.limit(10)
)
).all()
top_track_ids = [row[0] for row in top_track_ids_result]
if top_track_ids:
influence_rows = (
await session.execute(
select(FeedbackEvent, Track)
.join(Track, FeedbackEvent.track_id == Track.id)
.where(FeedbackEvent.track_id.in_(top_track_ids))
.order_by(FeedbackEvent.created_at.desc())
)
).all()
tracks_map: dict[int, dict] = {}
for event, track in influence_rows:
if track.id not in tracks_map:
tracks_map[track.id] = {
"title": track.title,
"artist": track.artist,
"vibes": [],
}
tracks_map[track.id]["vibes"].append({
"vibe_text": event.vibe_text or "no vibe",
"signal": event.signal,
"created_at": event.created_at,
})
# Preserve the top-by-count ordering
for tid in top_track_ids:
if tid in tracks_map:
vibe_influence.append(tracks_map[tid])
# Recent playlists (last 5)
playlist_rows = (
await session.execute(
@@ -143,6 +240,9 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess
profiles=profiles,
taste_profiles=taste_by_profile_id,
recent_playlists=recent_playlists,
feedback_summary=feedback_summary,
recent_feedback=recent_feedback,
vibe_influence=vibe_influence,
now=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
)
return HTMLResponse(html)

View File

@@ -23,5 +23,9 @@ class Settings(BaseSettings):
embedding_batch_size: int = 10
embedding_interval_seconds: int = 30
# Feedback
feedback_overlap_threshold: float = 0.85
feedback_signal_weights: dict = {"up": 1.0, "down": -1.0, "skip": -0.3}
settings = Settings()

View File

@@ -4,7 +4,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from haunt_fm.api import admin, health, history, playlists, profiles, recommendations, status, status_page
from haunt_fm.api import admin, feedback, health, history, playlists, profiles, recommendations, status, status_page
from haunt_fm.config import settings
logging.basicConfig(
@@ -49,3 +49,4 @@ app.include_router(profiles.router)
app.include_router(admin.router)
app.include_router(recommendations.router)
app.include_router(playlists.router)
app.include_router(feedback.router)

View File

@@ -115,9 +115,11 @@ class Playlist(Base):
total_tracks: Mapped[int] = mapped_column(Integer, nullable=False)
vibe: Mapped[str | None] = mapped_column(Text)
alpha: Mapped[float | None] = mapped_column(REAL)
vibe_embedding = mapped_column(Vector(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
tracks: Mapped[list["PlaylistTrack"]] = relationship(back_populates="playlist", cascade="all, delete-orphan")
feedback_events: Mapped[list["FeedbackEvent"]] = relationship(back_populates="playlist")
class PlaylistTrack(Base):
@@ -132,3 +134,23 @@ class PlaylistTrack(Base):
playlist: Mapped[Playlist] = relationship(back_populates="tracks")
track: Mapped[Track] = relationship()
class FeedbackEvent(Base):
__tablename__ = "feedback_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
playlist_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("playlists.id"), nullable=False)
track_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("tracks.id"), nullable=False)
vibe_embedding = mapped_column(Vector(512), nullable=False)
vibe_text: Mapped[str | None] = mapped_column(Text)
signal: Mapped[str] = mapped_column(Text, nullable=False)
signal_weight: Mapped[float] = mapped_column(REAL, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
Index("ix_feedback_events_track_id", "track_id"),
)
playlist: Mapped[Playlist] = relationship(back_populates="feedback_events")
track: Mapped[Track] = relationship()

View File

@@ -0,0 +1,167 @@
import logging
import numpy as np
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from haunt_fm.config import settings
from haunt_fm.models.track import FeedbackEvent, Playlist, Track
logger = logging.getLogger(__name__)
VALID_SIGNALS = frozenset(settings.feedback_signal_weights.keys())
async def record_feedback(
session: AsyncSession,
playlist_id: int,
track_id: int,
signal: str,
) -> FeedbackEvent:
"""Record a feedback event, copying vibe context from the playlist."""
if signal not in VALID_SIGNALS:
raise ValueError(f"Invalid signal '{signal}'. Must be one of: {', '.join(sorted(VALID_SIGNALS))}")
playlist = await session.get(Playlist, playlist_id)
if playlist is None:
raise ValueError(f"Playlist {playlist_id} not found")
if playlist.vibe_embedding is None:
raise ValueError(f"Playlist {playlist_id} has no vibe embedding — feedback requires vibe context")
track = await session.get(Track, track_id)
if track is None:
raise ValueError(f"Track {track_id} not found")
weight = settings.feedback_signal_weights[signal]
event = FeedbackEvent(
playlist_id=playlist_id,
track_id=track_id,
vibe_embedding=list(playlist.vibe_embedding),
vibe_text=playlist.vibe,
signal=signal,
signal_weight=weight,
)
session.add(event)
await session.commit()
await session.refresh(event)
logger.info("Recorded %s feedback for track %d in playlist %d (vibe: %s)",
signal, track_id, playlist_id, playlist.vibe)
return event
def compute_contextual_score(
events: list[FeedbackEvent],
current_vibe_embedding: np.ndarray,
threshold: float | None = None,
) -> float:
"""Compute a contextual feedback score for a track given a vibe.
Pure function — no DB access. Steps:
1. Greedy-cluster events by cosine similarity >= threshold (supersession)
2. Keep most recent event per cluster
3. Sum: signal_weight * cosine_sim(event.vibe_embedding, current_vibe_embedding)
"""
if not events:
return 0.0
if threshold is None:
threshold = settings.feedback_overlap_threshold
current_norm = np.linalg.norm(current_vibe_embedding)
if current_norm == 0:
return 0.0
current_unit = current_vibe_embedding / current_norm
# Sort by created_at descending so newest events are processed first
sorted_events = sorted(events, key=lambda e: e.created_at, reverse=True)
# Greedy supersession clustering: each event joins the first group whose
# representative (most recent event) has cosine_sim >= threshold, or starts a new group.
groups: list[FeedbackEvent] = [] # Representatives (most recent per group)
for event in sorted_events:
event_emb = np.array(event.vibe_embedding, dtype=np.float32)
event_norm = np.linalg.norm(event_emb)
if event_norm == 0:
continue
event_unit = event_emb / event_norm
merged = False
for i, rep in enumerate(groups):
rep_emb = np.array(rep.vibe_embedding, dtype=np.float32)
rep_norm = np.linalg.norm(rep_emb)
if rep_norm == 0:
continue
rep_unit = rep_emb / rep_norm
sim = float(np.dot(event_unit, rep_unit))
if sim >= threshold:
# This event belongs to this group. The representative is already
# the most recent (we iterate newest-first), so just skip.
merged = True
break
if not merged:
# New group — this event is the representative
groups.append(event)
# Score: sum over surviving representatives
score = 0.0
for rep in groups:
rep_emb = np.array(rep.vibe_embedding, dtype=np.float32)
rep_norm = np.linalg.norm(rep_emb)
if rep_norm == 0:
continue
rep_unit = rep_emb / rep_norm
vibe_sim = float(np.dot(rep_unit, current_unit))
score += rep.signal_weight * vibe_sim
return score
async def apply_feedback_adjustments(
session: AsyncSession,
recommendations: list[dict],
current_vibe_embedding: np.ndarray | None,
) -> list[dict]:
"""Adjust recommendation scores based on contextual feedback.
Fetches feedback events for the recommended tracks, computes contextual
scores, adds them to similarity, and re-sorts.
"""
if current_vibe_embedding is None or not recommendations:
return recommendations
track_ids = [r["track_id"] for r in recommendations]
result = await session.execute(
select(FeedbackEvent).where(FeedbackEvent.track_id.in_(track_ids))
)
events = list(result.scalars().all())
if not events:
return recommendations
# Group events by track_id
events_by_track: dict[int, list[FeedbackEvent]] = {}
for event in events:
events_by_track.setdefault(event.track_id, []).append(event)
# Compute adjusted scores
for rec in recommendations:
track_events = events_by_track.get(rec["track_id"], [])
if track_events:
feedback_score = compute_contextual_score(track_events, current_vibe_embedding)
rec["feedback_score"] = round(feedback_score, 4)
rec["adjusted_score"] = round(rec["similarity"] + feedback_score, 4)
else:
rec["feedback_score"] = 0.0
rec["adjusted_score"] = rec["similarity"]
# Re-sort by adjusted_score descending
recommendations.sort(key=lambda r: r["adjusted_score"], reverse=True)
return recommendations

View File

@@ -11,6 +11,7 @@ from haunt_fm.models.track import (
PlaylistTrack,
Track,
)
from haunt_fm.services.feedback import apply_feedback_adjustments
from haunt_fm.services.recommender import get_recommendations
logger = logging.getLogger(__name__)
@@ -58,7 +59,11 @@ async def generate_playlist(
profile_name=profile_name,
vibe_embedding=vibe_embedding, alpha=alpha,
)
new_tracks = [(r["track_id"], r["similarity"]) for r in recs[:new_count]]
# Apply feedback adjustments (re-ranks based on contextual feedback)
recs = await apply_feedback_adjustments(session, recs, vibe_embedding)
new_tracks = [(r["track_id"], r.get("adjusted_score", r["similarity"])) for r in recs[:new_count]]
# Interleave: spread known tracks throughout the playlist
playlist_items: list[tuple[int, bool, float | None]] = []
@@ -95,6 +100,7 @@ async def generate_playlist(
total_tracks=len(interleaved),
vibe=vibe_text,
alpha=alpha if vibe_text else None,
vibe_embedding=vibe_embedding.tolist() if vibe_embedding is not None else None,
)
session.add(playlist)
await session.flush()

View File

@@ -90,6 +90,32 @@
/* Empty state */
.empty { color: #484f58; font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; }
/* Feedback styles */
.feedback-summary {
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
color: #8b949e; font-size: 0.85rem; padding-bottom: 0.75rem;
border-bottom: 1px solid #21262d; margin-bottom: 0.5rem;
}
.signal-icon { font-weight: 700; font-size: 0.85rem; margin-right: 0.25rem; }
.signal-icon.up { color: #3fb950; }
.signal-icon.down { color: #f85149; }
.signal-icon.skip { color: #d29922; }
.feedback-item { padding: 0.5rem 0; border-bottom: 1px solid #21262d; }
.feedback-item:last-child { border-bottom: none; }
.feedback-track { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; }
.feedback-meta { color: #484f58; font-size: 0.8rem; margin-top: 0.15rem; }
.vibe-pill {
display: inline-flex; align-items: center; border-radius: 1rem;
padding: 0.2rem 0.6rem; font-size: 0.75rem; margin: 0.15rem 0.25rem 0.15rem 0;
}
.vibe-pill.up { background: rgba(63, 185, 80, 0.15); border: 1px solid #3fb950; color: #3fb950; }
.vibe-pill.down { background: rgba(248, 81, 73, 0.15); border: 1px solid #f85149; color: #f85149; }
.vibe-pill.skip { background: rgba(210, 153, 34, 0.15); border: 1px solid #d29922; color: #d29922; }
.influence-row { padding: 0.6rem 0; border-bottom: 1px solid #21262d; }
.influence-row:last-child { border-bottom: none; }
.influence-track { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; margin-bottom: 0.3rem; }
.influence-pills { display: flex; flex-wrap: wrap; }
</style>
</head>
<body>
@@ -137,6 +163,50 @@
{% endif %}
</div>
{% if feedback_summary.total > 0 %}
<!-- Feedback Activity -->
<div class="section">
<h2>Feedback Activity</h2>
<div class="feedback-summary">
<span>{{ feedback_summary.total }} events</span>
<span>&middot;</span>
<span>{{ feedback_summary.up }} <span class="signal-icon up">&#9650;</span></span>
<span>&middot;</span>
<span>{{ feedback_summary.down }} <span class="signal-icon down">&#9660;</span></span>
<span>&middot;</span>
<span>{{ feedback_summary.skip }} <span class="signal-icon skip">&#9656;</span></span>
<span>&middot;</span>
<span>{{ feedback_summary.tracks }} tracks</span>
</div>
{% for fb in recent_feedback %}
<div class="feedback-item">
<span class="signal-icon {{ fb.signal }}">{% if fb.signal == 'up' %}&#9650;{% elif fb.signal == 'down' %}&#9660;{% else %}&#9656;{% endif %}</span>
<span class="feedback-track">{{ fb.title }} &mdash; {{ fb.artist }}</span>
<div class="feedback-meta">{{ fb.vibe_text }} &middot; {{ fb.created_at | timeago }}</div>
</div>
{% endfor %}
</div>
<!-- Vibe Influence -->
<div class="section">
<h2>Vibe Influence</h2>
{% if vibe_influence %}
{% for track in vibe_influence %}
<div class="influence-row">
<div class="influence-track">{{ track.title }} &mdash; {{ track.artist }}</div>
<div class="influence-pills">
{% for vibe in track.vibes %}
<span class="vibe-pill {{ vibe.signal }}">{% if vibe.signal == 'up' %}&#9650;{% elif vibe.signal == 'down' %}&#9660;{% else %}&#9656;{% endif %} {{ vibe.vibe_text }}</span>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty">Submit feedback on vibe playlists to see how tracks are rated across different moods</div>
{% endif %}
</div>
{% endif %}
<!-- Pipeline + Tracks grid -->
<div class="grid">
<div class="section">