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:
kami
2026-08-10 13:59:16 +04:00
parent 0749f6ad10
commit 5a73a6a6f3
4 changed files with 55 additions and 3 deletions
+7 -2
View File
@@ -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 };
+8 -1
View File
@@ -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'