fix(vibe): let a dislike outlive the session that heard it
Disliking a track in a Vibe wrote one row to the session ledger and nothing else. The ledger only excludes a track from the session it was recorded in, so the same track came back the next evening, and the one after that. A dislike in a Vibe is the same verdict as a dislike anywhere else, so it now takes the same path. Two more things undid a dislike that did land. The library scan rewrote every track's state from the file on disk, which restored every HIDDEN track to LIBRARY on every scan; finding a file again says nothing about whether the listener wants to hear it. And hiding only matched tracks in LIBRARY, so a disliked probation recommendation stayed eligible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
This commit is contained in:
@@ -416,9 +416,14 @@ export class DbService {
|
|||||||
*/
|
*/
|
||||||
async dislikeTrack(userId: string, trackId: string): Promise<void> {
|
async dislikeTrack(userId: string, trackId: string): Promise<void> {
|
||||||
await this.withTransaction(async (client) => {
|
await this.withTransaction(async (client) => {
|
||||||
// Phase 1: hide the track in all active views
|
// Phase 1: hide the track in all active views. A probation recommendation
|
||||||
|
// is disliked the same way a library track is, and retires on the spot —
|
||||||
|
// the listener has answered the question probation exists to ask.
|
||||||
await client.query(
|
await client.query(
|
||||||
"UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'",
|
`UPDATE tracks
|
||||||
|
SET state = 'HIDDEN',
|
||||||
|
probation_status = CASE WHEN state = 'RECOMMENDED' THEN 'retired' ELSE probation_status END
|
||||||
|
WHERE id = $1 AND state IN ('LIBRARY', 'RECOMMENDED')`,
|
||||||
[trackId]
|
[trackId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function setup() {
|
|||||||
advancePastUnplayableVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }),
|
advancePastUnplayableVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }),
|
||||||
persistNextVibePlan: vi.fn().mockResolvedValue({ ...plan(), version: 2, reason: 'feedback:completed' }),
|
persistNextVibePlan: vi.fn().mockResolvedValue({ ...plan(), version: 2, reason: 'feedback:completed' }),
|
||||||
getVibePlanForFeedbackEvent: vi.fn().mockResolvedValue(null),
|
getVibePlanForFeedbackEvent: vi.fn().mockResolvedValue(null),
|
||||||
|
dislikeTrack: vi.fn().mockResolvedValue(undefined),
|
||||||
} as unknown as DbService;
|
} as unknown as DbService;
|
||||||
const director = {
|
const director = {
|
||||||
buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
|
buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
|
||||||
@@ -120,6 +121,37 @@ describe('VibeSessionCoordinator', () => {
|
|||||||
expect(db.persistNextVibePlan).not.toHaveBeenCalled();
|
expect(db.persistNextVibePlan).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records a durable dislike so the track is gone from later sessions too', async () => {
|
||||||
|
const { db, coordinator } = setup();
|
||||||
|
|
||||||
|
await coordinator.appendEvent('user-1', SESSION_ID, {
|
||||||
|
eventId: EVENT_ID, type: 'disliked', trackId: TRACK_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.dislikeTrack).toHaveBeenCalledWith('user-1', TRACK_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dislike a track twice when its event is delivered again', async () => {
|
||||||
|
const { db, coordinator } = setup();
|
||||||
|
(db.recordVibeEvent as any).mockResolvedValueOnce({
|
||||||
|
event: { id: 'event-1', client_event_id: EVENT_ID, type: 'disliked' }, inserted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.appendEvent('user-1', SESSION_ID, {
|
||||||
|
eventId: EVENT_ID, type: 'disliked', trackId: TRACK_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.dislikeTrack).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the library alone for feedback that is not a dislike', async () => {
|
||||||
|
const { db, coordinator } = setup();
|
||||||
|
|
||||||
|
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'skipped', trackId: TRACK_ID });
|
||||||
|
|
||||||
|
expect(db.dislikeTrack).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('recovers a material feedback replan when its first persistence attempt failed', async () => {
|
it('recovers a material feedback replan when its first persistence attempt failed', async () => {
|
||||||
const { db, coordinator } = setup();
|
const { db, coordinator } = setup();
|
||||||
(db.publishVibePlan as any).mockRejectedValueOnce(new Error('temporary database failure'));
|
(db.publishVibePlan as any).mockRejectedValueOnce(new Error('temporary database failure'));
|
||||||
|
|||||||
@@ -229,6 +229,14 @@ export class VibeSessionCoordinator {
|
|||||||
// guard for old coordinator test doubles during the migration.
|
// guard for old coordinator test doubles during the migration.
|
||||||
const projectSessionFeedback = (this.db as Partial<DbService>).projectVibeSessionFeedback;
|
const projectSessionFeedback = (this.db as Partial<DbService>).projectVibeSessionFeedback;
|
||||||
if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event);
|
if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event);
|
||||||
|
// A dislike in a Vibe is the same verdict as a dislike anywhere else. The
|
||||||
|
// ledger alone only excludes the track from this one session, which is
|
||||||
|
// why a disliked track kept coming back the next evening. Run it once per
|
||||||
|
// distinct event so a retried delivery cannot log a second feedback row.
|
||||||
|
if (input.type === 'disliked' && input.trackId && result.inserted) {
|
||||||
|
const dislikeTrack = (this.db as Partial<DbService>).dislikeTrack;
|
||||||
|
if (dislikeTrack) await dislikeTrack.call(this.db, userId, input.trackId);
|
||||||
|
}
|
||||||
if (!isMaterialFeedback(input.type)) {
|
if (!isMaterialFeedback(input.type)) {
|
||||||
const response = await this.getPlan(userId, sessionId);
|
const response = await this.getPlan(userId, sessionId);
|
||||||
return { ...response, event: result.event, idempotent: !result.inserted };
|
return { ...response, event: result.event, idempotent: !result.inserted };
|
||||||
|
|||||||
@@ -213,8 +213,15 @@ export class ScannerService {
|
|||||||
mtime = EXTRACT(EPOCH FROM NOW()),
|
mtime = EXTRACT(EPOCH FROM NOW()),
|
||||||
-- Existing recommendation rows stay recommendations during every
|
-- Existing recommendation rows stay recommendations during every
|
||||||
-- ordinary scan. This is load-bearing for probation and Vibe.
|
-- ordinary scan. This is load-bearing for probation and Vibe.
|
||||||
|
-- A dislike is a listener verdict on a file that is still on disk,
|
||||||
|
-- so finding that file again says nothing new: HIDDEN and DELETED
|
||||||
|
-- survive a rescan. Without this every scan restored every disliked
|
||||||
|
-- track to LIBRARY and the Vibe served it again.
|
||||||
state = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
state = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||||
THEN tracks.state ELSE EXCLUDED.state END,
|
THEN tracks.state
|
||||||
|
WHEN tracks.state IN ('HIDDEN', 'DELETED')
|
||||||
|
THEN tracks.state
|
||||||
|
ELSE EXCLUDED.state END,
|
||||||
source_type = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
source_type = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||||
THEN tracks.source_type ELSE $8::track_source_type END,
|
THEN tracks.source_type ELSE $8::track_source_type END,
|
||||||
probation_status = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
probation_status = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||||
|
|||||||
Reference in New Issue
Block a user