feat(playevents): persist session_vector_at_play on every record path

RecordPlayStarted and RecordSyntheticCompletedPlay both capture the
session vector inside their existing transactions. Single private
helper queries the prior 5 tracks, builds the vector, UPDATEs the
just-inserted play_event. Tests verify the seed flag boundary and
session-scope isolation.
This commit is contained in:
2026-04-27 11:19:16 -04:00
parent 627b7d1be5
commit d6acb3b63c
2 changed files with 123 additions and 0 deletions
+40
View File
@@ -9,6 +9,7 @@ package playevents
import (
"context"
"encoding/json"
"errors"
"log/slog"
"time"
@@ -19,6 +20,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playsessions"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
type Writer struct {
@@ -87,6 +89,11 @@ func (w *Writer) RecordPlayStarted(
}
out.PlayEventID = ev.ID
out.SessionID = sessionID
// Capture session vector for the just-inserted row.
if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil {
return err
}
return nil
})
return out, err
@@ -223,6 +230,9 @@ func (w *Writer) RecordSyntheticCompletedPlay(
if err != nil {
return err
}
if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil {
return err
}
ratio := 1.0
dur := track.DurationMs
_, err = q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
@@ -236,6 +246,36 @@ func (w *Writer) RecordSyntheticCompletedPlay(
})
}
// captureSessionVector queries the user's prior plays in the given session
// (before `at`), builds the session vector, and UPDATEs the just-inserted
// play_event with it. Runs inside the caller's transaction (q is the
// transaction-bound *dbq.Queries). Errors here propagate — vector capture
// is best-effort, but DB errors should fail the transaction.
func (w *Writer) captureSessionVector(
ctx context.Context,
q *dbq.Queries,
playEventID, sessionID pgtype.UUID,
at time.Time,
) error {
priorTracks, err := q.ListRecentSessionTracks(ctx, dbq.ListRecentSessionTracksParams{
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
Limit: 5,
})
if err != nil {
return err
}
vec := recommendation.BuildSessionVector(priorTracks)
vecJSON, err := json.Marshal(vec)
if err != nil {
return err
}
return q.UpdatePlayEventVector(ctx, dbq.UpdatePlayEventVectorParams{
ID: playEventID,
SessionVectorAtPlay: vecJSON,
})
}
// autoClosePriorOpen closes any open (ended_at IS NULL) play_event for the
// user. Sets ended_at = at, duration_played_ms = min(at - started_at, track
// duration), was_skipped = true. Skip rule is NOT applied — auto-closed