/library/decisions/dj-agent
ADR 0008 — Capture taste before you own the file (parked reviews)
The taste layer (ADR 0003) is what makes this my DJ. But the only way to feed it is dj.taste.tag, which requires the track to already be in the vibe DB — and ingestion requires the raw audio file (…
mirrored from ~/dev/dj-agent/docs/adr/0008-spotify-taste-capture.md · commit 52419d2 · synced 2026.07.09Accepted
Status: Accepted
Date: 2026-06-04
Extends: 0003-personal-taste-layer.md
Context
The taste layer (ADR 0003) is what makes this my DJ. But the only way to feed
it is dj.taste.tag, which requires the track to already be in the vibe DB —
and ingestion requires the raw audio file (streaming is excluded; beatmatching
needs the file). So capturing taste is gated behind owning + ingesting the file.
The problem: the strongest taste signal happens while listening — and I mostly listen on Spotify, with no file in hand. "Hands-in-the-air, perfect 3am closer" is a thought I have in the moment and lose by the time I've bought the track, dropped it in a folder, and run the Curator. The friction kills the loop ADR 0003 depends on (tag ~150 favorites). Tagging-against-a-path is a data-entry chore divorced from the listening moment.
We want to leave a review the instant we feel it, with no file, and have it apply itself later if/when the track enters the library — no second pass.
Decision
Add a pending-review store that decouples taste capture from ingest:
- Capture anywhere, no file needed. A review is
(artist, title, note, rating?, role?)plus whatever identity we can grab (isrc,spotify_id,duration_s). Two front-ends, both routed through the agent so they share one sink (agents/tools.save_review→taste/pending.add):- "review what's playing" — the agent reads the Spotify MCP
get_currently_playingfor identity, then saves (source='spotify_now'). - conversational chat — the agent parses "save a review for X: …" and saves
(
source='chat'). This path has no Spotify dependency, so it survives any change to the Spotify MCP. A plain CLI (dj.taste.review) covers manual entry + the parked-review inbox.
- "review what's playing" — the agent reads the Spotify MCP
- One store, same Postgres. A
pending_tastetable besidetracks— not a second database (reaffirmsADR 0001/0003: separation lives in tables/columns). The note is stored as raw text and embedded at apply-time, so the store stays human-readable/editable and re-embeds free if the taste model changes. - The Curator drains it at ingest. After upserting a track, the Curator looks
for a parked review matching the track's identity and, on a confident match,
writes the note into
tracks.taste_*exactly asdj.taste.tagwould (set_taste,taste_source='manual') — no extra input — then marks the review applied (so re-ingest won't re-fire; idempotent like the rest). - Confident-only auto-apply; ISRC-first matching. Match tiers:
ISRC→ unique normalized(artist, title)+ duration within ±5 s → confident. Several name hits, or a duration mismatch → ambiguous: left parked and surfaced for a one-line manual resolve (--apply <id> <path>). Never silently tag the wrong recording (live vs studio, remix vs original).
Rationale
- Capture at the moment of feeling. The whole taste loop hinges on actually leaving notes; removing the file-first gate is the difference between a loop that runs and one that doesn't.
- ISRC is the right key. It identifies the recording, robust to title punctuation/qualifiers. Name+duration is the always-available fallback; together they cover the common cases without a fingerprinting dependency (we have no Spotify audio to fingerprint anyway).
- Conservative by default. A wrong auto-apply silently poisons the taste layer; parking the ambiguous cases costs one confirmation and protects the signal.
- Reuses the existing sink. A parked review lands through
set_taste, identical to a hand-typed tag — propagation, blending, and the Selector need no changes.
Trade-offs accepted
- Fuzzy name matching is imperfect.
&-joined collabs can miss; aggressive qualifier-stripping could, in rare cases, collide two different tracks. ISRC + duration + the manual resolve are the guards; thresholds start strict and tune on real data. get_currently_playingmay not surface ISRC. If the MCP omitsexternal_ids.isrc, capture falls back to name+duration (and can recover ISRC via asearch()); matching still works, just one tier weaker.- Spotify becomes a taste input, not only Phase-6 export. A small scope expansion, justified: it's where the listening (and the feeling) happens.
Consequences
vibe/schema.sql: new additivepending_tastetable + status/isrc indexes.- New
taste/pending.py(pure normalization +find_matchtiers; DB-guardedadd/list_pending/match/apply_review/apply_manual) andtaste/review.py(CLI).metadata.pygains best-effortisrcextraction.curator.pydrains pending reviews after upsert (with a never-clobber-my-manual-label guard).agents/tools.pyexposessave_review/list_pending_reviews. .claude/skills/vibe-review/— the chat front-end recipe.- Pure matching is unit-tested (
tests/test_pending.py); the DB/model/Spotify edges stay lazy + guarded, so the fast suite still runs with no DB/model/audio.