798 lines
27 KiB
Markdown
798 lines
27 KiB
Markdown
# v2 Fix Plan — foolproof execution
|
||
|
||
This plan fixes the gaps between the overnight v2 work and
|
||
`docs/architecture/09-recommendation-and-identity-v2.md`. Every step
|
||
has exact file paths, exact old/new code, and a verification command.
|
||
Execute steps 1–8 in order, then step 9 (build + deploy + verify).
|
||
|
||
**Rules for the executing agent:**
|
||
|
||
- Do NOT edit or remove existing entries in the `MIGRATIONS` array in
|
||
`db.service.ts`. The three v2 migrations (`20260707_claim_fusion`,
|
||
`20260707_backfill_claims`, `20260708_materialize_claim_fusion`) have
|
||
not applied to the live DB yet, but leave them as-is — they run
|
||
cleanly on first boot.
|
||
- Do NOT delete the v1 `getNextVibeChunk` CTE or `vibe.routes.ts` in
|
||
this plan. The doc says v1 is deleted *when D ships and is
|
||
verified*. That's a follow-up, not this plan.
|
||
- After all code edits (steps 1–7), run `npx tsc --noEmit` and
|
||
`npx vitest run` from `backend/` before deploying.
|
||
- All file paths are relative to `/home/kami/apps/muzick/`.
|
||
|
||
---
|
||
|
||
## Step 1 — claim_fusion MV refresh consumer (blocker)
|
||
|
||
**Problem:** `claim_fusion` is a MATERIALIZED VIEW. It is populated at
|
||
creation time (migration) and never refreshed again. A trigger fires
|
||
`NOTIFY claim_fusion_changed` on claims changes, but nothing LISTENs.
|
||
Every generator and compat view reads the frozen MV — new claims are
|
||
invisible.
|
||
|
||
**Fix:** Add a `refreshClaimFusion()` method to `DbService` and a
|
||
background interval in `app.ts` that calls it every 10 seconds.
|
||
`CONCURRENTLY` won't block reads.
|
||
|
||
### 1a. Add method to `backend/src/services/db.service.ts`
|
||
|
||
Insert this method immediately before the closing `}` of the class
|
||
(after `seedDefaultDiversityBudgets`, which ends at line 2062):
|
||
|
||
```ts
|
||
|
||
/**
|
||
* Refresh the claim_fusion materialised view. Called on a periodic
|
||
* timer so the graph's read path stays current with new claims.
|
||
* CONCURRENTLY requires the unique index (idx_claim_fusion_pk),
|
||
* which the 20260708_materialize_claim_fusion migration creates.
|
||
*/
|
||
async refreshClaimFusion(): Promise<void> {
|
||
try {
|
||
await this.pgClient.query('SELECT refresh_claim_fusion()');
|
||
} catch (err) {
|
||
// Non-fatal: the MV may not exist yet on first boot before
|
||
// migrations run. Log and move on; the next tick will retry.
|
||
console.error('[DB] refresh_claim_fusion failed:', err);
|
||
}
|
||
}
|
||
```
|
||
|
||
The `oldString` to match for the edit (the end of
|
||
`seedDefaultDiversityBudgets` + the class closing brace):
|
||
|
||
```
|
||
for (const d of defaults) {
|
||
await this.upsertDiversityBudget({
|
||
user_id: userId,
|
||
dimension: d.dimension,
|
||
budget_share: d.share,
|
||
horizon_min: d.horizon,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Replace with the same block + the new method inserted before the
|
||
final `}`.
|
||
|
||
### 1b. Start the refresh interval in `backend/src/app.ts`
|
||
|
||
After the line `await dbService.runMigrations();` (line 53), add:
|
||
|
||
```ts
|
||
|
||
// Keep the claim_fusion materialised view fresh. The trigger on
|
||
// `claims` fires NOTIFY on every change; rather than maintain a
|
||
// LISTEN consumer (separate long-lived connection), we refresh on a
|
||
// short interval. 10s staleness is well below any user-facing
|
||
// latency for a homelab music player.
|
||
const FUSION_REFRESH_MS = 10_000;
|
||
const fusionTimer = setInterval(() => {
|
||
dbService.refreshClaimFusion().catch(() => {});
|
||
}, FUSION_REFRESH_MS);
|
||
```
|
||
|
||
Then in the `onClose` hook (around line 128), add `clearInterval` for
|
||
the new timer. Find:
|
||
|
||
```ts
|
||
fastify.addHook('onClose', async () => {
|
||
try {
|
||
await pgClient.end();
|
||
```
|
||
|
||
Insert before `await pgClient.end();`:
|
||
|
||
```ts
|
||
clearInterval(fusionTimer);
|
||
```
|
||
|
||
**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors.
|
||
|
||
---
|
||
|
||
## Step 2 — daily belief decay + nightly forgotten derivation (blocker)
|
||
|
||
**Problem:** `listener_beliefs.last_decayed_at` is set at insert and
|
||
never advanced. No decay job exists. This violates the core axiom
|
||
"everything decays unless reinforced" and reproduces the v1 failure
|
||
mode (heavily-played artists win forever). Also, the `forgotten`
|
||
profile is "derived nightly" per the doc but nothing populates it, so
|
||
the revival generator always returns empty.
|
||
|
||
**Fix:** Add `decayBeliefs()` and `deriveForgottenProfile()` methods
|
||
to `DbService` and periodic intervals in `app.ts`.
|
||
|
||
### 2a. Add decay method to `backend/src/services/db.service.ts`
|
||
|
||
Insert after `refreshClaimFusion()` (the method added in step 1a):
|
||
|
||
```ts
|
||
|
||
/**
|
||
* Decay all listener beliefs whose last_decayed_at is older than 1
|
||
* hour. Implements the decay formula from spec §B.4:
|
||
* value *= 0.5 ^ (elapsed / halflife)
|
||
* confidence *= 0.5 ^ (elapsed / halflife)
|
||
* Halflife is per-profile (longterm=365d, obsession=14d, discovery=30d,
|
||
* negative=180d, contextual=7d). The 'forgotten' profile is excluded
|
||
* — it is fully derived nightly by deriveForgottenProfile(), not
|
||
* decayed.
|
||
*/
|
||
async decayBeliefs(): Promise<number> {
|
||
const res = await this.pgClient.query(`
|
||
WITH halflives AS (
|
||
SELECT profile,
|
||
CASE profile
|
||
WHEN 'longterm' THEN 365 * 86400
|
||
WHEN 'obsession' THEN 14 * 86400
|
||
WHEN 'discovery' THEN 30 * 86400
|
||
WHEN 'negative' THEN 180 * 86400
|
||
WHEN 'contextual' THEN 7 * 86400
|
||
ELSE 30 * 86400
|
||
END AS halflife_sec
|
||
)
|
||
UPDATE listener_beliefs lb
|
||
SET value = GREATEST(-1.0, LEAST(1.0, lb.value * POWER(0.5,
|
||
EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))),
|
||
confidence = GREATEST(0, LEAST(1.0, lb.confidence * POWER(0.5,
|
||
EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))),
|
||
last_decayed_at = NOW()
|
||
FROM halflives h
|
||
WHERE lb.profile = h.profile
|
||
AND lb.profile <> 'forgotten'
|
||
AND lb.last_decayed_at < NOW() - INTERVAL '1 hour'
|
||
`);
|
||
return res.rowCount ?? 0;
|
||
}
|
||
|
||
/**
|
||
* Derive the 'forgotten' profile nightly (spec §B.2):
|
||
* longterm affinity > 0.3 AND not reinforced in 90+ days.
|
||
* Wipes and repopulates — 'forgotten' is fully derived, not evidence-fed.
|
||
*/
|
||
async deriveForgottenProfile(): Promise<number> {
|
||
await this.pgClient.query(
|
||
`DELETE FROM listener_beliefs WHERE profile = 'forgotten'`
|
||
);
|
||
const res = await this.pgClient.query(`
|
||
INSERT INTO listener_beliefs
|
||
(user_id, profile, entity_type, entity_id, dimension, value,
|
||
confidence, evidence_count, last_reinforced_at, last_decayed_at)
|
||
SELECT user_id, 'forgotten', entity_type, entity_id, dimension,
|
||
value, confidence, evidence_count, last_reinforced_at, NOW()
|
||
FROM listener_beliefs
|
||
WHERE profile = 'longterm'
|
||
AND dimension = 'affinity'
|
||
AND value > 0.3
|
||
AND last_reinforced_at < NOW() - INTERVAL '90 days'
|
||
ON CONFLICT (user_id, profile, entity_type, entity_id, dimension)
|
||
DO UPDATE SET
|
||
value = EXCLUDED.value,
|
||
confidence = EXCLUDED.confidence,
|
||
evidence_count = EXCLUDED.evidence_count,
|
||
last_reinforced_at = EXCLUDED.last_reinforced_at
|
||
`);
|
||
return res.rowCount ?? 0;
|
||
}
|
||
```
|
||
|
||
### 2b. Start decay + forgotten intervals in `backend/src/app.ts`
|
||
|
||
After the `fusionTimer` block added in step 1b, add:
|
||
|
||
```ts
|
||
|
||
// Daily belief decay (spec §B.4). Runs hourly; the SQL only touches
|
||
// beliefs whose last_decayed_at is >1h old, so frequent runs are safe.
|
||
const DECAY_INTERVAL_MS = 60 * 60 * 1000;
|
||
const decayTimer = setInterval(() => {
|
||
dbService.decayBeliefs().catch((e) => console.error('[DB] belief decay failed:', e));
|
||
}, DECAY_INTERVAL_MS);
|
||
|
||
// Nightly 'forgotten' profile derivation (spec §B.2).
|
||
const FORGOTTEN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||
const forgottenTimer = setInterval(() => {
|
||
dbService.deriveForgottenProfile().catch((e) =>
|
||
console.error('[DB] forgotten derivation failed:', e)
|
||
);
|
||
}, FORGOTTEN_INTERVAL_MS);
|
||
|
||
// Run both once at boot so the first session benefits.
|
||
dbService.decayBeliefs().catch(() => {});
|
||
dbService.deriveForgottenProfile().catch(() => {});
|
||
```
|
||
|
||
In the `onClose` hook, add (after `clearInterval(fusionTimer);`):
|
||
|
||
```ts
|
||
clearInterval(decayTimer);
|
||
clearInterval(forgottenTimer);
|
||
```
|
||
|
||
**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors.
|
||
|
||
---
|
||
|
||
## Step 3 — fix MB spine writer generated-column bug
|
||
|
||
**File:** `workers/src/mb-spine-writer.ts`
|
||
|
||
**Problem:** `resolveArtist` (line 128) tries to INSERT into
|
||
`normalized_name`, which is `GENERATED ALWAYS AS normalize_artist(name)
|
||
STORED`. PostgreSQL rejects this:
|
||
`ERROR: cannot insert a non-DEFAULT value into column "normalized_name"`.
|
||
The stub-creation path is broken — the spine writer can only attach
|
||
claims to *existing* artists; any newly-credited artist is dropped.
|
||
|
||
**Fix:** Remove `normalized_name` from the INSERT column list and the
|
||
`normalized` value from the params. The generated column auto-computes
|
||
from `name`.
|
||
|
||
Find (lines 127–134):
|
||
|
||
```ts
|
||
const result = await this.pgClient.query<{ id: string }>(
|
||
`INSERT INTO artists (name, canonical_name, sort_name, mbid, normalized_name)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (mbid) DO UPDATE SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP
|
||
RETURNING id`,
|
||
[creditName, artistName, sortName, mbid, normalized]
|
||
);
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```ts
|
||
const result = await this.pgClient.query<{ id: string }>(
|
||
`INSERT INTO artists (name, canonical_name, sort_name, mbid)
|
||
VALUES ($1, $2, $3, $4)
|
||
ON CONFLICT (mbid) DO UPDATE SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP
|
||
RETURNING id`,
|
||
[creditName, artistName, sortName, mbid]
|
||
);
|
||
```
|
||
|
||
**Verify:** `npx tsc --noEmit` in `workers/` — 0 errors.
|
||
|
||
---
|
||
|
||
## Step 4 — fix dislikeTrack evidence-in-catch bug
|
||
|
||
**File:** `backend/src/services/db.service.ts`
|
||
|
||
**Problem:** `dislikeTrack` (line 712) writes the `hidden` evidence
|
||
row in the `catch` block — i.e. only when the transaction **fails**.
|
||
A successful dislike writes no negative evidence via this path.
|
||
|
||
**Fix:** Move the evidence write out of the catch block to after the
|
||
try/catch, so it runs only on success.
|
||
|
||
Find (lines 712–748):
|
||
|
||
```ts
|
||
async dislikeTrack(userId: string, trackId: string): Promise<void> {
|
||
try {
|
||
await this.pgClient.query('BEGIN');
|
||
|
||
// Phase 1: hide the track in all active views
|
||
await this.pgClient.query(
|
||
"UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'",
|
||
[trackId]
|
||
);
|
||
|
||
// Phase 1: insert dislike row (idempotent — won't create duplicate)
|
||
await this.pgClient.query(
|
||
'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING',
|
||
[trackId]
|
||
);
|
||
|
||
// Phase 1: log feedback signal for the Vibe learning loop
|
||
await this.pgClient.query(
|
||
"INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')",
|
||
[userId, trackId]
|
||
);
|
||
|
||
await this.pgClient.query('COMMIT');
|
||
} catch (err) {
|
||
await this.pgClient.query('ROLLBACK');
|
||
// Write evidence: hidden → negative profile
|
||
await this.recordEvidence({
|
||
user_id: userId,
|
||
entity_type: 'track',
|
||
entity_id: trackId,
|
||
signal: 'hidden',
|
||
profile: 'negative',
|
||
weight: -0.60,
|
||
});
|
||
throw err;
|
||
}
|
||
}
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```ts
|
||
async dislikeTrack(userId: string, trackId: string): Promise<void> {
|
||
try {
|
||
await this.pgClient.query('BEGIN');
|
||
|
||
// Phase 1: hide the track in all active views
|
||
await this.pgClient.query(
|
||
"UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'",
|
||
[trackId]
|
||
);
|
||
|
||
// Phase 1: insert dislike row (idempotent — won't create duplicate)
|
||
await this.pgClient.query(
|
||
'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING',
|
||
[trackId]
|
||
);
|
||
|
||
// Phase 1: log feedback signal for the Vibe learning loop
|
||
await this.pgClient.query(
|
||
"INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')",
|
||
[userId, trackId]
|
||
);
|
||
|
||
await this.pgClient.query('COMMIT');
|
||
} catch (err) {
|
||
await this.pgClient.query('ROLLBACK');
|
||
throw err;
|
||
}
|
||
|
||
// Write evidence: hidden → negative profile (only on success)
|
||
await this.recordEvidence({
|
||
user_id: userId,
|
||
entity_type: 'track',
|
||
entity_id: trackId,
|
||
signal: 'hidden',
|
||
profile: 'negative',
|
||
weight: -0.60,
|
||
});
|
||
}
|
||
```
|
||
|
||
**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors.
|
||
|
||
---
|
||
|
||
## Step 5 — fix hardDeleteTrack missing manual_deleted evidence
|
||
|
||
**File:** `backend/src/services/db.service.ts`
|
||
|
||
**Problem:** `hardDeleteTrack` (line 810) writes a legacy `feedback`
|
||
row but no evidence. The doc's strongest negative signal
|
||
(`manual_deleted → negative -0.90`) is missing. Permanent deletion has
|
||
no effect on listener beliefs.
|
||
|
||
**Fix:** Add a `recordEvidence` call between the feedback insert and
|
||
the track DELETE. `evidence.entity_id` has no FK to `tracks`, so the
|
||
evidence row survives the deletion.
|
||
|
||
Find (lines 813–821):
|
||
|
||
```ts
|
||
// Log the permanent deletion feedback event first (before the track is gone)
|
||
await this.pgClient.query(
|
||
"INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')",
|
||
[userId, trackId]
|
||
);
|
||
|
||
// Delete DB record (ON DELETE CASCADE handles track_genre, play_history,
|
||
// feedback, track_audio_features, track_lyrics, recommendation_batch_track)
|
||
await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]);
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```ts
|
||
// Log the permanent deletion feedback event first (before the track is gone)
|
||
await this.pgClient.query(
|
||
"INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')",
|
||
[userId, trackId]
|
||
);
|
||
|
||
// Write evidence: manual_deleted → negative profile (strongest negative
|
||
// signal, spec §B.3). entity_id has no FK to tracks, so the row survives.
|
||
await this.recordEvidence({
|
||
user_id: userId,
|
||
entity_type: 'track',
|
||
entity_id: trackId,
|
||
signal: 'manual_deleted',
|
||
profile: 'negative',
|
||
weight: -0.90,
|
||
});
|
||
|
||
// Delete DB record (ON DELETE CASCADE handles track_genre, play_history,
|
||
// feedback, track_audio_features, track_lyrics, recommendation_batch_track)
|
||
await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]);
|
||
```
|
||
|
||
**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors.
|
||
|
||
---
|
||
|
||
## Step 6 — switch session director artist reads to track_artists_v2
|
||
|
||
**File:** `backend/src/services/session-director.service.ts`
|
||
|
||
**Problem:** The session director reads the legacy `track_artists`
|
||
table for artist fatigue, budget spend, repetition checks, seed
|
||
resolution, and recent-play artist lookup. Doc D.2 requires artist
|
||
fatigue to roll up via `alias_of` fusion (so DOOM / Madvillain / Viktor
|
||
Vaughn collapse into one artist). By bypassing `claim_fusion` /
|
||
`track_artists_v2`, the alias collapse cannot happen.
|
||
|
||
**Fix:** Replace all `track_artists ta` → `track_artists_v2 ta` and
|
||
`track_artists ta3` → `track_artists_v2 ta3` in this file. The v2 view
|
||
has the same `track_id`, `artist_id`, `role` columns, so it's a
|
||
drop-in replacement. This depends on step 1 (MV refresh) being
|
||
deployed so the view has data.
|
||
|
||
There are 11 occurrences across the file. Do two `replaceAll` edits:
|
||
|
||
### 6a. Replace all `track_artists ta` with `track_artists_v2 ta`
|
||
|
||
Use `replaceAll: true` on the string `track_artists ta` →
|
||
`track_artists_v2 ta`. This covers 10 occurrences (buildState,
|
||
computeFatigue, calcBudgetSpent artist, calcBudgetSpent new_artist,
|
||
checkRepetition, rankCandidates, buildPlan, replan, resolveSeedArtistId).
|
||
|
||
### 6b. Replace `track_artists ta3` with `track_artists_v2 ta3`
|
||
|
||
Use `replaceAll: true` on the string `track_artists ta3` →
|
||
`track_artists_v2 ta3`. This covers 1 occurrence in
|
||
`calcBudgetSpent`'s `new_artist` case.
|
||
|
||
**Note:** Do step 6a first, then 6b. After 6a, the `ta3` occurrence
|
||
will still be `track_artists ta3` (it wasn't matched by `track_artists ta`
|
||
because that's a different string — `ta3` ≠ `ta`). So both
|
||
replacements are needed.
|
||
|
||
**Verify:**
|
||
- `npx tsc --noEmit` in `backend/` — 0 errors.
|
||
- `grep -n "track_artists " backend/src/services/session-director.service.ts`
|
||
should return **zero** matches (all replaced). If any `track_artists `
|
||
without `_v2` remain, fix them.
|
||
|
||
---
|
||
|
||
## Step 7 — correct the session log
|
||
|
||
**File:** `SESSION-07-07-2026.md`
|
||
|
||
**Problem:** The session log overclaims: says "Full Stack" and
|
||
"replaces getNextVibeChunk" but nothing is deployed, v1 is intact, and
|
||
frontend is not wired. Also miscounts files (7 not 8) and says
|
||
noveltyGenerator was "skipped" when it's implemented.
|
||
|
||
**Fix:** Make these edits:
|
||
|
||
### 7a. Fix the headline (line 3)
|
||
|
||
Find:
|
||
```
|
||
## Implemented: v2 Recommendation Engine — Full Stack (Systems A–E + Phase 4)
|
||
```
|
||
Replace with:
|
||
```
|
||
## Scaffolded: v2 Recommendation Engine — code complete, not yet deployed (Systems A–E + Phase 4)
|
||
```
|
||
|
||
### 7b. Fix the file count (line 8)
|
||
|
||
Find:
|
||
```
|
||
### Files created (7 new)
|
||
```
|
||
Replace with:
|
||
```
|
||
### Files created (8 new)
|
||
```
|
||
|
||
And add a row to the table after the `image-enrichment.service.ts` row
|
||
(line 15). After:
|
||
```
|
||
| `backend/src/services/image-enrichment.service.ts` | 105 | **Phase 4** — Image candidate pipeline |
|
||
```
|
||
Add:
|
||
```
|
||
| `workers/src/mb-spine-writer.ts` | 136 | **A** — MB artist-credit → claims writer (wired into enrichment.service.ts) |
|
||
```
|
||
|
||
### 7c. Fix the novelty generator claim (line 53)
|
||
|
||
Find:
|
||
```
|
||
- `noveltyGenerator`: skipped (no release_date column)
|
||
```
|
||
Replace with:
|
||
```
|
||
- `noveltyGenerator`: recent releases (≤60d) via same_scene_as/same_label_as/produced edges from trusted artists
|
||
```
|
||
|
||
### 7d. Fix the "replaces getNextVibeChunk" claim (line 56)
|
||
|
||
Find:
|
||
```
|
||
**System D — Session Director (replaces getNextVibeChunk)**
|
||
```
|
||
Replace with:
|
||
```
|
||
**System D — Session Director (runs alongside v1; getNextVibeChunk not yet deleted)**
|
||
```
|
||
|
||
### 7e. Fix the Verification section (lines 86–88)
|
||
|
||
Find:
|
||
```
|
||
### Verification
|
||
- `npx tsc --noEmit` — 0 errors
|
||
- No git repo — changes uncommitted
|
||
```
|
||
Replace with:
|
||
```
|
||
### Verification
|
||
- `npx tsc --noEmit` — 0 errors
|
||
- `npx vitest run` — 30/30 pass (mocked shape checks, not DB-state)
|
||
- No git repo — changes uncommitted
|
||
- NOT deployed: live backend container is pre-v2; `/api/v2/*` and `/api/graph/*` return 404; DB has zero v2 tables. See `docs/architecture/v2-fix-plan.md` for the fix + deploy plan.
|
||
```
|
||
|
||
### 7f. Fix the Next section (lines 89–92)
|
||
|
||
Find:
|
||
```
|
||
### Next
|
||
- Wire the v2 endpoint into the frontend Vibe page (replace v1 vibeService calls)
|
||
- Build yt-dlp worker for System E acquisition (download candidates)
|
||
- Runtime smoke-test after redeploy
|
||
```
|
||
Replace with:
|
||
```
|
||
### Next
|
||
- Execute `docs/architecture/v2-fix-plan.md` (MV refresh, decay job, bug fixes, deploy)
|
||
- After deploy + verify: wire the v2 endpoint into the frontend Vibe page (replace v1 vibeService calls)
|
||
- Build yt-dlp worker for System E acquisition (download candidates)
|
||
- After v2 is verified in production: delete v1 CTE (`getNextVibeChunk`), `vibe.routes.ts`, `feedback` table, `artist_similar` table per the doc's "Retiring v1" list
|
||
```
|
||
|
||
---
|
||
|
||
## Step 8 — run typecheck + tests before deploying
|
||
|
||
```bash
|
||
cd /home/kami/apps/muzick/backend
|
||
npx tsc --noEmit
|
||
npx vitest run
|
||
```
|
||
|
||
Both must pass (0 ts errors, 30/30 tests). If any test fails, do not
|
||
deploy — re-read the relevant step and fix.
|
||
|
||
Also typecheck the worker:
|
||
|
||
```bash
|
||
cd /home/kami/apps/muzick/workers
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
---
|
||
|
||
## Step 9 — build, deploy, and verify against the live DB
|
||
|
||
### 9a. Rebuild the backend + worker images
|
||
|
||
```bash
|
||
cd /home/kami/apps/muzick
|
||
docker compose build backend worker
|
||
docker compose up -d backend worker
|
||
```
|
||
|
||
Wait ~15 seconds for boot, then check the backend logs for migration
|
||
output:
|
||
|
||
```bash
|
||
docker logs muzick-backend-1 --tail 80 2>&1 | grep -E "Migration|migration|ERROR|error"
|
||
```
|
||
|
||
You should see:
|
||
```
|
||
[DB] Running migration: 20260707_claim_fusion
|
||
[DB] Migration applied: 20260707_claim_fusion
|
||
[DB] Running migration: 20260707_backfill_claims
|
||
[DB] Migration applied: 20260707_backfill_claims
|
||
[DB] Running migration: 20260708_materialize_claim_fusion
|
||
[DB] Migration applied: 20260708_materialize_claim_fusion
|
||
```
|
||
|
||
If any migration fails, read the error, fix the SQL in a NEW migration
|
||
(do not edit the failed one), rebuild, and redeploy.
|
||
|
||
### 9b. Verify v2 tables + views exist and are populated
|
||
|
||
```bash
|
||
docker exec muzick-db-1 psql -U user -d muzick -c "
|
||
SELECT 'migrations' AS check, COUNT(*) FROM schema_migrations
|
||
UNION ALL SELECT 'claims', COUNT(*) FROM claims
|
||
UNION ALL SELECT 'source_trust', COUNT(*) FROM source_trust
|
||
UNION ALL SELECT 'evidence', COUNT(*) FROM evidence
|
||
UNION ALL SELECT 'claim_fusion rows', COUNT(*) FROM claim_fusion
|
||
UNION ALL SELECT 'track_artists_v2 rows', COUNT(*) FROM track_artists_v2
|
||
UNION ALL SELECT 'recording_mbid set', COUNT(*) FROM tracks WHERE recording_mbid IS NOT NULL;
|
||
"
|
||
```
|
||
|
||
Expected after first boot:
|
||
- `migrations` = 7 (4 old + 3 new)
|
||
- `source_trust` = 7 (seed rows)
|
||
- `claims` > 0 (backfilled from `track_artists` + `artist_similar`)
|
||
- `claim_fusion rows` > 0 (populated by the materialize migration)
|
||
- `track_artists_v2 rows` > 0 (view over claim_fusion)
|
||
|
||
If `claims` = 0, the backfill migration found nothing — check that
|
||
`track_artists` and `artist_similar` have data in the live DB.
|
||
|
||
### 9c. Verify the v2 endpoints are live
|
||
|
||
```bash
|
||
curl -s http://localhost:3000/api/graph/sources | head -c 200
|
||
echo
|
||
curl -s -o /dev/null -w "v2/state: HTTP %{http_code}\n" http://localhost:3000/api/v2/state
|
||
curl -s -o /dev/null -w "graph/sources: HTTP %{http_code}\n" http://localhost:3000/api/graph/sources
|
||
curl -s -o /dev/null -w "graph/summary: HTTP %{http_code}\n" http://localhost:3000/api/graph/summary
|
||
```
|
||
|
||
Expected: `v2/state: HTTP 200`, `graph/sources: HTTP 200`,
|
||
`graph/summary: HTTP 200`.
|
||
|
||
### 9d. Smoke-test the v2 session flow
|
||
|
||
```bash
|
||
# Start a v2 session (use any library track ID as seed)
|
||
SEED=$(docker exec muzick-db-1 psql -U user -d muzick -t -c "SELECT id FROM tracks WHERE state='LIBRARY' LIMIT 1" | tr -d ' \n')
|
||
echo "Seed track: $SEED"
|
||
curl -s -X POST http://localhost:3000/api/v2/vibe/start \
|
||
-H 'Content-Type: application/json' \
|
||
-H "x-user-id: 00000000-0000-0000-0000-000000000000" \
|
||
-d "{\"seedTrackId\":\"$SEED\"}" | head -c 500
|
||
echo
|
||
# Get the next track from the plan
|
||
curl -s http://localhost:3000/api/v2/vibe/next \
|
||
-H "x-user-id: 00000000-0000-0000-0000-000000000000" | head -c 300
|
||
echo
|
||
# Check the plan
|
||
curl -s http://localhost:3000/api/v2/vibe/plan \
|
||
-H "x-user-id: 00000000-0000-0000-0000-000000000000" | head -c 300
|
||
```
|
||
|
||
If `/v2/vibe/start` returns an empty plan `[]`, check the backend logs
|
||
for generator errors. The most likely cause is `claim_fusion` being
|
||
empty — verify step 9b showed `claim_fusion rows > 0`.
|
||
|
||
### 9e. Verify evidence is written on a completed play
|
||
|
||
```bash
|
||
USER="00000000-0000-0000-0000-000000000000"
|
||
TRACK=$(docker exec muzick-db-1 psql -U user -d muzick -t -c "SELECT id FROM tracks WHERE state='LIBRARY' LIMIT 1" | tr -d ' \n')
|
||
|
||
# Before
|
||
docker exec muzick-db-1 psql -U user -d muzick -c "SELECT COUNT(*) AS evidence_before FROM evidence WHERE user_id='$USER'"
|
||
|
||
# Record a completed play via the v2 feedback endpoint
|
||
curl -s -X POST http://localhost:3000/api/v2/vibe/feedback \
|
||
-H 'Content-Type: application/json' \
|
||
-H "x-user-id: $USER" \
|
||
-d "{\"trackId\":\"$TRACK\",\"action\":\"completed\"}"
|
||
|
||
# After
|
||
docker exec muzick-db-1 psql -U user -d muzick -c "SELECT COUNT(*) AS evidence_after, signal, profile, weight FROM evidence WHERE user_id='$USER' GROUP BY signal, profile, weight ORDER BY created_at DESC LIMIT 5"
|
||
```
|
||
|
||
Expected: `evidence_after > evidence_before`, and you should see a
|
||
`playback_completed` / `longterm` / `0.10` row.
|
||
|
||
### 9f. Verify the MV refresh is running
|
||
|
||
Wait 15 seconds after boot, then:
|
||
|
||
```bash
|
||
docker logs muzick-backend-1 2>&1 | grep -i "refresh_claim_fusion" | tail -3
|
||
```
|
||
|
||
You should see no errors (the method logs only on failure). If you see
|
||
repeated `refresh_claim_fusion failed` errors, the MV or refresh
|
||
function doesn't exist — re-check that the
|
||
`20260708_materialize_claim_fusion` migration applied.
|
||
|
||
### 9g. Verify belief decay runs
|
||
|
||
```bash
|
||
# Manually trigger decay and check it doesn't error
|
||
docker exec muzick-backend-1 node -e "
|
||
const { Client } = require('pg');
|
||
const c = new Client({ connectionString: process.env.DATABASE_URL });
|
||
(async () => {
|
||
await c.connect();
|
||
const r = await c.query('SELECT decay_beliefs()');
|
||
console.log('decay result:', r.rows);
|
||
await c.end();
|
||
})().catch(e => { console.error('FAIL:', e.message); process.exit(1); });
|
||
" 2>&1 || echo "decay_beliefs() not a SQL function — that's OK, the method runs the UPDATE directly"
|
||
```
|
||
|
||
This is a soft check — the `decayBeliefs()` method runs raw SQL, not a
|
||
stored function. The real verification is that `last_decayed_at`
|
||
advances after 1 hour. Check:
|
||
|
||
```bash
|
||
docker exec muzick-db-1 psql -U user -d muzick -c "
|
||
SELECT user_id, profile, entity_type, last_decayed_at,
|
||
EXTRACT(EPOCH FROM (NOW() - last_decayed_at))/3600 AS hours_since_decay
|
||
FROM listener_beliefs
|
||
ORDER BY last_decayed_at DESC LIMIT 5;
|
||
"
|
||
```
|
||
|
||
After 1+ hours of uptime, `hours_since_decay` should be < 1 for
|
||
recently-decayed rows (the hourly job touched them).
|
||
|
||
---
|
||
|
||
## Summary of what each step fixes
|
||
|
||
| Step | Doc section | Problem | Fix |
|
||
|---|---|---|---|
|
||
| 1 | A.4 | `claim_fusion` MV never refreshed | 10s interval calls `refresh_claim_fusion()` |
|
||
| 2 | B.4, B.2 | No belief decay; `forgotten` never derived | Hourly decay job + 24h forgotten derivation |
|
||
| 3 | A.5 #1 | MB spine writer can't create new artists (generated column) | Drop `normalized_name` from INSERT |
|
||
| 4 | B.3 | `dislikeTrack` writes evidence only on failure | Move evidence write to success path |
|
||
| 5 | B.3 | `hardDeleteTrack` writes no `manual_deleted` evidence | Add `-0.90` evidence before track DELETE |
|
||
| 6 | D.2 | Director reads legacy `track_artists`, bypassing alias fusion | Switch to `track_artists_v2` |
|
||
| 7 | — | Session log overclaims | Correct the wording |
|
||
| 8 | — | Pre-deploy gate | tsc + vitest pass |
|
||
| 9 | A.7, B.6, D.11 | Not deployed; acceptance unverified | Build, deploy, verify against live DB |
|
||
|
||
## What is NOT in this plan (follow-ups, not blockers)
|
||
|
||
- **MB spine writer album + artist-relation claims** —
|
||
`writeAlbumClaims` and `writeArtistRelationClaims` in
|
||
`mb-spine-writer.ts` are stubs (`console.log` + `return 0`). They
|
||
require new `MusicBrainzClient` methods (release-group artist-credit,
|
||
artist-relations ARs). Not a blocker for first deploy; the recording-
|
||
claim writer is the critical path. Implement as a follow-up.
|
||
- **Frontend wiring** — the frontend still calls `/api/vibe/*` (v1).
|
||
After v2 is verified in production, wire `/api/v2/vibe/*` into
|
||
`frontend/src/services/vibeService.ts` and the Vibe page.
|
||
- **v1 deletion** — `getNextVibeChunk`, `vibe.routes.ts`, the
|
||
`feedback` table, `artist_similar` table, `genre.parent_id` are all
|
||
still present. The doc says delete them when D ships and is
|
||
verified. That's a separate, careful release after v2 is confirmed
|
||
good in production.
|
||
- **LISTEN-based MV refresh** — the 10s interval in step 1 is the
|
||
simple, foolproof approach. Upgrading to a `LISTEN`/`NOTIFY` consumer
|
||
(immediate refresh on claim change) is a follow-up if 10s staleness
|
||
ever becomes a problem.
|