feat: M3 session vectors + contextual_likes capture #22
Reference in New Issue
Block a user
Delete Branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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