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:
@@ -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,
|
||||
|
||||
44
alembic/versions/004_add_feedback_events.py
Normal file
44
alembic/versions/004_add_feedback_events.py
Normal 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")
|
||||
126
src/haunt_fm/api/feedback.py
Normal file
126
src/haunt_fm/api/feedback.py
Normal 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),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
167
src/haunt_fm/services/feedback.py
Normal file
167
src/haunt_fm/services/feedback.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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>·</span>
|
||||
<span>{{ feedback_summary.up }} <span class="signal-icon up">▲</span></span>
|
||||
<span>·</span>
|
||||
<span>{{ feedback_summary.down }} <span class="signal-icon down">▼</span></span>
|
||||
<span>·</span>
|
||||
<span>{{ feedback_summary.skip }} <span class="signal-icon skip">▸</span></span>
|
||||
<span>·</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' %}▲{% elif fb.signal == 'down' %}▼{% else %}▸{% endif %}</span>
|
||||
<span class="feedback-track">{{ fb.title }} — {{ fb.artist }}</span>
|
||||
<div class="feedback-meta">{{ fb.vibe_text }} · {{ 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 }} — {{ track.artist }}</div>
|
||||
<div class="influence-pills">
|
||||
{% for vibe in track.vibes %}
|
||||
<span class="vibe-pill {{ vibe.signal }}">{% if vibe.signal == 'up' %}▲{% elif vibe.signal == 'down' %}▼{% else %}▸{% 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">
|
||||
|
||||
Reference in New Issue
Block a user