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 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)