feat: M3 session vectors + contextual_likes capture #22

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 19:56:25 -04:00
bvandeusen commented 2026-04-27 17:21:48 -04:00 (Migrated from git.fabledsword.com)

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: UnlikeTrackSoftDeleteContextualLikes.

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)
  • internal/recommendation + internal/playevents combined coverage: 78.5% (target: ≥ 70%)
  • cd web && npm run check — 0 errors / 0 warnings
  • 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.

🤖 Generated with Claude Code

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)
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#22