From 31f13b1efb81d5a71e08105ebaff87559499dcb6 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 23 Feb 2026 07:34:00 -0600 Subject: [PATCH] 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 --- alembic/env.py | 1 + alembic/versions/004_add_feedback_events.py | 44 ++++++ src/haunt_fm/api/feedback.py | 126 +++++++++++++++ src/haunt_fm/api/playlists.py | 1 + src/haunt_fm/api/recommendations.py | 5 + src/haunt_fm/api/status_page.py | 100 ++++++++++++ src/haunt_fm/config.py | 4 + src/haunt_fm/main.py | 3 +- src/haunt_fm/models/track.py | 22 +++ src/haunt_fm/services/feedback.py | 167 ++++++++++++++++++++ src/haunt_fm/services/playlist_generator.py | 8 +- src/haunt_fm/templates/status.html | 70 ++++++++ 12 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/004_add_feedback_events.py create mode 100644 src/haunt_fm/api/feedback.py create mode 100644 src/haunt_fm/services/feedback.py diff --git a/alembic/env.py b/alembic/env.py index 118c75f..6c69c8d 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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, diff --git a/alembic/versions/004_add_feedback_events.py b/alembic/versions/004_add_feedback_events.py new file mode 100644 index 0000000..294981c --- /dev/null +++ b/alembic/versions/004_add_feedback_events.py @@ -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") diff --git a/src/haunt_fm/api/feedback.py b/src/haunt_fm/api/feedback.py new file mode 100644 index 0000000..4c6f563 --- /dev/null +++ b/src/haunt_fm/api/feedback.py @@ -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), + } diff --git a/src/haunt_fm/api/playlists.py b/src/haunt_fm/api/playlists.py index a7baf84..a2d1589 100644 --- a/src/haunt_fm/api/playlists.py +++ b/src/haunt_fm/api/playlists.py @@ -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, diff --git a/src/haunt_fm/api/recommendations.py b/src/haunt_fm/api/recommendations.py index 7946836..2d57cd7 100644 --- a/src/haunt_fm/api/recommendations.py +++ b/src/haunt_fm/api/recommendations.py @@ -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"} diff --git a/src/haunt_fm/api/status_page.py b/src/haunt_fm/api/status_page.py index f18809d..0136194 100644 --- a/src/haunt_fm/api/status_page.py +++ b/src/haunt_fm/api/status_page.py @@ -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) diff --git a/src/haunt_fm/config.py b/src/haunt_fm/config.py index e85b8ec..2e35c38 100644 --- a/src/haunt_fm/config.py +++ b/src/haunt_fm/config.py @@ -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() diff --git a/src/haunt_fm/main.py b/src/haunt_fm/main.py index b71bd98..f153fae 100644 --- a/src/haunt_fm/main.py +++ b/src/haunt_fm/main.py @@ -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) diff --git a/src/haunt_fm/models/track.py b/src/haunt_fm/models/track.py index 4b6c68a..613be16 100644 --- a/src/haunt_fm/models/track.py +++ b/src/haunt_fm/models/track.py @@ -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() diff --git a/src/haunt_fm/services/feedback.py b/src/haunt_fm/services/feedback.py new file mode 100644 index 0000000..2beb264 --- /dev/null +++ b/src/haunt_fm/services/feedback.py @@ -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 diff --git a/src/haunt_fm/services/playlist_generator.py b/src/haunt_fm/services/playlist_generator.py index 9f24f9e..b4b8a00 100644 --- a/src/haunt_fm/services/playlist_generator.py +++ b/src/haunt_fm/services/playlist_generator.py @@ -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() diff --git a/src/haunt_fm/templates/status.html b/src/haunt_fm/templates/status.html index 5df00bb..1d76c9d 100644 --- a/src/haunt_fm/templates/status.html +++ b/src/haunt_fm/templates/status.html @@ -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; } @@ -137,6 +163,50 @@ {% endif %} + {% if feedback_summary.total > 0 %} + +
+

Feedback Activity

+ + {% for fb in recent_feedback %} + + {% endfor %} +
+ + +
+

Vibe Influence

+ {% if vibe_influence %} + {% for track in vibe_influence %} +
+
{{ track.title }} — {{ track.artist }}
+
+ {% for vibe in track.vibes %} + {% if vibe.signal == 'up' %}▲{% elif vibe.signal == 'down' %}▼{% else %}▸{% endif %} {{ vibe.vibe_text }} + {% endfor %} +
+
+ {% endfor %} + {% else %} +
Submit feedback on vibe playlists to see how tracks are rated across different moods
+ {% endif %} +
+ {% endif %} +