Second M3 sub-plan (Fable #341). Adds the data-capture write paths the recommendation engine consumes for contextual matching. Backend-only; no UI surface.
What this ships
Per spec §6 "Session vector" + §13 step 8. Sub-plan #3 (Fable #342) reads the data added here to compute the contextual_match_score term in the scoring formula.
Schema (migration 0007_contextual_likes)
The contextual_likes table didn't exist — it was referenced in 0005's comments but never created. This migration ships it for the first time:
Partial index (user_id, track_id) WHERE deleted_at IS NULL — hot path for the engine's lookups and the soft-delete UPDATE.
GIN index on session_vector — vector similarity queries from sub-plan #3.
Session vector at play_started
internal/recommendation/sessionvector.go exports SessionVector (JSON-serializable: seed bool, artists []string, tags map[string]int, recent_track_ids []string) and pure function BuildSessionVector([]dbq.Track) SessionVector. Rules:
seed = len(priorTracks) < 3 (engine in #342 filters seed vectors out of contextual matching).
Artists deduplicated, ordered by first appearance.
Tags is a bag-of-counts over tracks.genre. Empty/nil genres skipped.
RecentTrackIDs preserves input order, newest last.
internal/playevents.Writer.RecordPlayStarted and RecordSyntheticCompletedPlay both gain a private captureSessionVector helper that runs inside their existing transactions: queries up to 5 prior tracks in the session via new ListRecentSessionTracks SQL, builds the vector, persists to play_events.session_vector_at_play via new UpdatePlayEventVector SQL.
Contextual likes capture on like
internal/playevents exports two new helpers used by both internal/api/likes.go and internal/subsonic/star.go:
CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger) — best-effort: looks up the user's open play_event; if present and its session_vector_at_play is non-NULL, INSERTs into contextual_likes with the vector + session_id snapshot. Errors are logged and swallowed (capture is best-effort; must not break the like response).
SoftDeleteContextualLikes(ctx, q, userID, trackID) — UPDATEs all currently-active rows for (user, track) to set deleted_at = now() WHERE deleted_at IS NULL. Idempotent.
LikeTrack annotation changed from :exec to :execrows so handlers can detect insert-vs-already-exists (no contextual capture on idempotent re-likes — avoids spam).
API integration
internal/api/likes.go::handleLikeTrack — captures rows count from LikeTrack; if rows == 1, calls CaptureContextualLikeIfPlaying.
internal/api/likes.go::handleUnlikeTrack — calls SoftDeleteContextualLikes after UnlikeTrack.
mediaHandlers struct gains a logger *slog.Logger field threaded through Mount.
Test plan
go test -short -race ./... — all packages pass
golangci-lint run ./... — clean
Full integration suite (MINSTREL_TEST_DATABASE_URL=… go test -p 1 ./...) — passes; library scanner integration test is the same pre-existing flake we've documented in prior PRs (unrelated to this slice)
cd web && npm test — 174 tests across 29 files (no web changes; regression check)
cd web && npm run build — adapter-static emits web/build/
docker build -t minstrel:m3-vectors-smoke . + binary smoke — ok
Manual: play 4 tracks; verify vectors transition seed→non-seed at the 4th play; like a track during play (contextual_likes row); like another without play (no row); unlike (deleted_at populated); re-like in new session (new row, history accumulates); Subsonic star during a now-playing also captures.
Notes
contextual_likes table missing from 0005: the M2 events migration commented "ships nullable now" but the actual CREATE TABLE was missed. Fixed in 0007.
Soft-delete vs hard-delete: per the brainstorm, contextual_likes are soft-deleted on unlike. Preserves history; the engine queries WHERE deleted_at IS NULL via the partial index.
Best-effort capture: contextual capture failures are logged but never fail the like response. The general_likes write is the source-of-truth; contextual is an enrichment.
Subsonic synthetic completed plays carry vectors too: RecordSyntheticCompletedPlay runs the same captureSessionVector helper, so Subsonic-driven plays from submission=true are first-class citizens of the recommendation pipeline.
No web changes: this slice is data plumbing. Sub-plan #3 (next) wires the data into the scoring formula and closes M3.
Second M3 sub-plan (Fable #341). Adds the data-capture write paths the recommendation engine consumes for contextual matching. Backend-only; no UI surface.
## What this ships
Per spec §6 "Session vector" + §13 step 8. Sub-plan #3 (Fable #342) reads the data added here to compute the `contextual_match_score` term in the scoring formula.
### Schema (migration `0007_contextual_likes`)
The `contextual_likes` table didn't exist — it was referenced in 0005's comments but never created. This migration ships it for the first time:
- `contextual_likes(id, user_id, track_id, liked_at, deleted_at, session_vector jsonb, session_id)`
- Partial index `(user_id, track_id) WHERE deleted_at IS NULL` — hot path for the engine's lookups and the soft-delete UPDATE.
- GIN index on `session_vector` — vector similarity queries from sub-plan #3.
### Session vector at play_started
`internal/recommendation/sessionvector.go` exports `SessionVector` (JSON-serializable: `seed bool`, `artists []string`, `tags map[string]int`, `recent_track_ids []string`) and pure function `BuildSessionVector([]dbq.Track) SessionVector`. Rules:
- `seed = len(priorTracks) < 3` (engine in #342 filters seed vectors out of contextual matching).
- Artists deduplicated, ordered by first appearance.
- Tags is a bag-of-counts over `tracks.genre`. Empty/nil genres skipped.
- RecentTrackIDs preserves input order, newest last.
`internal/playevents.Writer.RecordPlayStarted` and `RecordSyntheticCompletedPlay` both gain a private `captureSessionVector` helper that runs inside their existing transactions: queries up to 5 prior tracks in the session via new `ListRecentSessionTracks` SQL, builds the vector, persists to `play_events.session_vector_at_play` via new `UpdatePlayEventVector` SQL.
### Contextual likes capture on like
`internal/playevents` exports two new helpers used by both `internal/api/likes.go` and `internal/subsonic/star.go`:
- `CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger)` — best-effort: looks up the user's open play_event; if present and its `session_vector_at_play` is non-NULL, INSERTs into `contextual_likes` with the vector + session_id snapshot. Errors are logged and swallowed (capture is best-effort; must not break the like response).
- `SoftDeleteContextualLikes(ctx, q, userID, trackID)` — UPDATEs all currently-active rows for `(user, track)` to set `deleted_at = now() WHERE deleted_at IS NULL`. Idempotent.
`LikeTrack` annotation changed from `:exec` to `:execrows` so handlers can detect insert-vs-already-exists (no contextual capture on idempotent re-likes — avoids spam).
### API integration
- `internal/api/likes.go::handleLikeTrack` — captures `rows` count from `LikeTrack`; if `rows == 1`, calls `CaptureContextualLikeIfPlaying`.
- `internal/api/likes.go::handleUnlikeTrack` — calls `SoftDeleteContextualLikes` after `UnlikeTrack`.
- `internal/subsonic/star.go::handleStar` (track branch) — same pattern: `LikeTrack` rows count → optional capture.
- `internal/subsonic/star.go::handleUnstar` (track branch) — same: `UnlikeTrack` → `SoftDeleteContextualLikes`.
`mediaHandlers` struct gains a `logger *slog.Logger` field threaded through `Mount`.
## Test plan
- [x] `go test -short -race ./...` — all packages pass
- [x] `golangci-lint run ./...` — clean
- [x] Full integration suite (`MINSTREL_TEST_DATABASE_URL=… go test -p 1 ./...`) — passes; library scanner integration test is the same pre-existing flake we've documented in prior PRs (unrelated to this slice)
- [x] **`internal/recommendation` + `internal/playevents` combined coverage: 78.5%** (target: ≥ 70%)
- [x] `cd web && npm run check` — 0 errors / 0 warnings
- [x] `cd web && npm test` — 174 tests across 29 files (no web changes; regression check)
- [x] `cd web && npm run build` — adapter-static emits `web/build/`
- [x] `docker build -t minstrel:m3-vectors-smoke .` + binary smoke — ok
- [ ] Manual: play 4 tracks; verify vectors transition seed→non-seed at the 4th play; like a track during play (contextual_likes row); like another without play (no row); unlike (deleted_at populated); re-like in new session (new row, history accumulates); Subsonic star during a now-playing also captures.
## Notes
- **`contextual_likes` table missing from 0005**: the M2 events migration commented "ships nullable now" but the actual `CREATE TABLE` was missed. Fixed in 0007.
- **Soft-delete vs hard-delete**: per the brainstorm, contextual_likes are soft-deleted on unlike. Preserves history; the engine queries `WHERE deleted_at IS NULL` via the partial index.
- **Best-effort capture**: contextual capture failures are logged but never fail the like response. The `general_likes` write is the source-of-truth; contextual is an enrichment.
- **Subsonic synthetic completed plays carry vectors too**: `RecordSyntheticCompletedPlay` runs the same `captureSessionVector` helper, so Subsonic-driven plays from `submission=true` are first-class citizens of the recommendation pipeline.
- **No web changes**: this slice is data plumbing. Sub-plan #3 (next) wires the data into the scoring formula and closes M3.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Second M3 sub-plan (Fable #341). Adds the data-capture write paths the recommendation engine consumes for contextual matching. Backend-only; no UI surface.
What this ships
Per spec §6 "Session vector" + §13 step 8. Sub-plan #3 (Fable #342) reads the data added here to compute the
contextual_match_scoreterm in the scoring formula.Schema (migration
0007_contextual_likes)The
contextual_likestable didn't exist — it was referenced in 0005's comments but never created. This migration ships it for the first time:contextual_likes(id, user_id, track_id, liked_at, deleted_at, session_vector jsonb, session_id)(user_id, track_id) WHERE deleted_at IS NULL— hot path for the engine's lookups and the soft-delete UPDATE.session_vector— vector similarity queries from sub-plan #3.Session vector at play_started
internal/recommendation/sessionvector.goexportsSessionVector(JSON-serializable:seed bool,artists []string,tags map[string]int,recent_track_ids []string) and pure functionBuildSessionVector([]dbq.Track) SessionVector. Rules:seed = len(priorTracks) < 3(engine in #342 filters seed vectors out of contextual matching).tracks.genre. Empty/nil genres skipped.internal/playevents.Writer.RecordPlayStartedandRecordSyntheticCompletedPlayboth gain a privatecaptureSessionVectorhelper that runs inside their existing transactions: queries up to 5 prior tracks in the session via newListRecentSessionTracksSQL, builds the vector, persists toplay_events.session_vector_at_playvia newUpdatePlayEventVectorSQL.Contextual likes capture on like
internal/playeventsexports two new helpers used by bothinternal/api/likes.goandinternal/subsonic/star.go:CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger)— best-effort: looks up the user's open play_event; if present and itssession_vector_at_playis non-NULL, INSERTs intocontextual_likeswith the vector + session_id snapshot. Errors are logged and swallowed (capture is best-effort; must not break the like response).SoftDeleteContextualLikes(ctx, q, userID, trackID)— UPDATEs all currently-active rows for(user, track)to setdeleted_at = now() WHERE deleted_at IS NULL. Idempotent.LikeTrackannotation changed from:execto:execrowsso handlers can detect insert-vs-already-exists (no contextual capture on idempotent re-likes — avoids spam).API integration
internal/api/likes.go::handleLikeTrack— capturesrowscount fromLikeTrack; ifrows == 1, callsCaptureContextualLikeIfPlaying.internal/api/likes.go::handleUnlikeTrack— callsSoftDeleteContextualLikesafterUnlikeTrack.internal/subsonic/star.go::handleStar(track branch) — same pattern:LikeTrackrows count → optional capture.internal/subsonic/star.go::handleUnstar(track branch) — same:UnlikeTrack→SoftDeleteContextualLikes.mediaHandlersstruct gains alogger *slog.Loggerfield threaded throughMount.Test plan
go test -short -race ./...— all packages passgolangci-lint run ./...— cleanMINSTREL_TEST_DATABASE_URL=… go test -p 1 ./...) — passes; library scanner integration test is the same pre-existing flake we've documented in prior PRs (unrelated to this slice)internal/recommendation+internal/playeventscombined coverage: 78.5% (target: ≥ 70%)cd web && npm run check— 0 errors / 0 warningscd web && npm test— 174 tests across 29 files (no web changes; regression check)cd web && npm run build— adapter-static emitsweb/build/docker build -t minstrel:m3-vectors-smoke .+ binary smoke — okNotes
contextual_likestable missing from 0005: the M2 events migration commented "ships nullable now" but the actualCREATE TABLEwas missed. Fixed in 0007.WHERE deleted_at IS NULLvia the partial index.general_likeswrite is the source-of-truth; contextual is an enrichment.RecordSyntheticCompletedPlayruns the samecaptureSessionVectorhelper, so Subsonic-driven plays fromsubmission=trueare first-class citizens of the recommendation pipeline.🤖 Generated with Claude Code