feat(discover): full reset — clear + rebuild suggestions

POST /api/discover/reset sets discover.resetRequested; the worker, at the
start of the next sweep, clears all PENDING suggestions + contributions +
seed throttles and nulls WatchedArtist.lastDiscoveredAt (keeping
followed/wanted/dismissed decisions), then regenerates everything with current
logic. A 'Reset' button on /discover (2-step confirm) triggers it.

worker 247, web 190 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-15 00:20:17 +02:00
parent 7e27654b5a
commit 9915bfbda2
6 changed files with 113 additions and 8 deletions
@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { POST } from "./route";
describe("POST /api/discover/reset", () => {
it("sets the reset + requested flags so the worker rebuilds on the next sweep", async () => {
const res = await POST();
expect(res.status).toBe(202);
expect((await res.json()).reset).toBe(true);
const cfg = Object.fromEntries(
(await prisma.config.findMany({ where: { key: { in: ["discover.resetRequested", "discover.requested"] } } }))
.map((c) => [c.key, c.value]),
);
expect(cfg["discover.resetRequested"]).toBe("true");
expect(cfg["discover.requested"]).toBe("true");
});
});
+17
View File
@@ -0,0 +1,17 @@
import { prisma } from "@/lib/db";
// POST /api/discover/reset — request a full rebuild: the worker clears the pending
// suggestions, contributions, and seed throttles at the start of the next sweep, then
// regenerates everything with the current logic. Kicks a sweep immediately.
export async function POST() {
await Promise.all(
["discover.resetRequested", "discover.requested"].map((key) =>
prisma.config.upsert({
where: { key },
create: { key, value: "true", secret: false },
update: { value: "true" },
}),
),
);
return Response.json({ reset: true }, { status: 202 });
}
+26
View File
@@ -69,6 +69,7 @@ export function DiscoverClient() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [search, setSearch] = useState<SearchResult | null>(null); const [search, setSearch] = useState<SearchResult | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set()); const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
const [albumModal, setAlbumModal] = useState<{ a: Album; artistName: string; artistMbid: string } | null>(null); const [albumModal, setAlbumModal] = useState<{ a: Album; artistName: string; artistMbid: string } | null>(null);
@@ -106,6 +107,18 @@ export function DiscoverClient() {
setRunning(true); setRunning(true);
} }
async function resetDiscover() {
setConfirmReset(false);
const res = await fetch("/api/discover/reset", { method: "POST" }).catch(() => null);
if (res && (res.ok || res.status === 202)) {
setRows([]); // clears immediately; the rebuild repopulates
setRunning(true);
toast("Rebuilding suggestions from scratch…");
} else {
toast("Couldn't reset");
}
}
async function runSearch(e: React.FormEvent) { async function runSearch(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!query.trim()) return; if (!query.trim()) return;
@@ -169,6 +182,17 @@ export function DiscoverClient() {
<button className="btn accent" onClick={discoverNow} disabled={running}> <button className="btn accent" onClick={discoverNow} disabled={running}>
{running ? "Discovering…" : "Discover now"} {running ? "Discovering…" : "Discover now"}
</button> </button>
{confirmReset ? (
<>
<span className="rmeta">Clear all suggestions and rebuild from scratch?</span>
<button className="btn sm accent" onClick={resetDiscover}>Reset</button>
<button className="btn sm ghost" onClick={() => setConfirmReset(false)}>Cancel</button>
</>
) : (
<>
<button className="btn sm ghost" onClick={() => setConfirmReset(true)} disabled={running}>
Reset
</button>
<span className="rmeta"> <span className="rmeta">
{running {running
? "Sweeping your followed artists for new suggestions…" ? "Sweeping your followed artists for new suggestions…"
@@ -176,6 +200,8 @@ export function DiscoverClient() {
? `Last discovered ${agoLabel(lastRunAt)}${result ? ` · ${result}` : ""}` ? `Last discovered ${agoLabel(lastRunAt)}${result ? ` · ${result}` : ""}`
: "Not yet run — press Discover now to sweep your followed artists"} : "Not yet run — press Discover now to sweep your followed artists"}
</span> </span>
</>
)}
</div> </div>
<SectionHeader title="Find similar" /> <SectionHeader title="Find similar" />
+12
View File
@@ -315,6 +315,18 @@ def _recompute_suggestions(conn: psycopg.Connection, affected: set[str],
return surfaced return surfaced
def reset_pending(conn: psycopg.Connection) -> None:
"""Clear discovery state so the next sweep rebuilds from scratch with current logic. Drops
all PENDING suggestions (followed/wanted/dismissed user decisions are kept), every seed
contribution + library-seed throttle, and marks each watched artist due again."""
with conn.cursor() as cur:
cur.execute("DELETE FROM \"DiscoverySuggestion\" WHERE status = 'pending'")
cur.execute('DELETE FROM "DiscoverySeedContribution"')
cur.execute('DELETE FROM "DiscoverySeed"')
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = NULL')
conn.commit()
def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource], def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource],
browser: MbBrowser, cfg: DiscoveryConfig, popularity=None) -> DiscoveryResult: browser: MbBrowser, cfg: DiscoveryConfig, popularity=None) -> DiscoveryResult:
"""Record each seed's similar-artist contributions, then recompute affected """Record each seed's similar-artist contributions, then recompute affected
+5 -1
View File
@@ -10,7 +10,7 @@ from lyra_worker.claim import claim_next, reclaim_stuck_jobs
from lyra_worker.config import get_config from lyra_worker.config import get_config
from lyra_worker.crypto import secret_key_problem from lyra_worker.crypto import secret_key_problem
from lyra_worker.db import wait_for_db from lyra_worker.db import wait_for_db
from lyra_worker.discovery import DiscoveryConfig, DiscoveryResult, run_discovery from lyra_worker.discovery import DiscoveryConfig, DiscoveryResult, reset_pending, run_discovery
from lyra_worker.library import clear_staging_root from lyra_worker.library import clear_staging_root
from lyra_worker.monitor import MonitorConfig, fulfill_owned_releases, reconcile, sweep from lyra_worker.monitor import MonitorConfig, fulfill_owned_releases, reconcile, sweep
from lyra_worker.pipeline import run_pipeline from lyra_worker.pipeline import run_pipeline
@@ -147,6 +147,10 @@ def maybe_run_discovery(conn, sources, browser, config, dcfg, now, last_discover
starting = not in_progress starting = not in_progress
if starting: # begin a fresh sweep if starting: # begin a fresh sweep
if str(config.get("discover.resetRequested", "")).strip().lower() in _TRUE:
reset_pending(conn) # wipe stale pending suggestions before rebuilding
_set_config(conn, "discover.resetRequested", "false")
print("worker: discovery reset — cleared pending suggestions, rebuilding", flush=True)
_set_config(conn, "discover.inProgress", "true") _set_config(conn, "discover.inProgress", "true")
_set_config(conn, "discover.requested", "false") _set_config(conn, "discover.requested", "false")
+29
View File
@@ -277,3 +277,32 @@ def test_run_discovery_processes_at_most_chunk_size_seeds(conn):
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT count(*) FROM "WatchedArtist" WHERE "lastDiscoveredAt" IS NOT NULL') cur.execute('SELECT count(*) FROM "WatchedArtist" WHERE "lastDiscoveredAt" IS NOT NULL')
assert cur.fetchone()[0] == 2 assert cur.fetchone()[0] == 2
def test_reset_pending_clears_state_and_marks_seeds_due(conn):
from lyra_worker.discovery import reset_pending
from tests.conftest import insert_discovery_suggestion
insert_watched_artist(conn, mbid="s1", name="Seed", last_discovered_sql="now()")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c1", status="pending")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c2", status="dismissed")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c3", status="wanted")
with conn.cursor() as cur:
cur.execute('INSERT INTO "DiscoverySeedContribution" (id, "candidateMbid", "candidateName", '
'"seedMbid", score, sources, "updatedAt") VALUES '
"(gen_random_uuid()::text, 'c1', 'C1', 's1', 1.0, '{}', now())")
cur.execute('INSERT INTO "DiscoverySeed" (mbid, "lastDiscoveredAt") VALUES (%s, now())', ("libseed",))
conn.commit()
reset_pending(conn)
with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM \"DiscoverySuggestion\" WHERE status='pending'")
assert cur.fetchone()[0] == 0 # pending cleared
cur.execute("SELECT count(*) FROM \"DiscoverySuggestion\" WHERE status IN ('dismissed','wanted')")
assert cur.fetchone()[0] == 2 # user decisions kept
cur.execute('SELECT count(*) FROM "DiscoverySeedContribution"')
assert cur.fetchone()[0] == 0
cur.execute('SELECT count(*) FROM "DiscoverySeed"')
assert cur.fetchone()[0] == 0
cur.execute('SELECT count(*) FROM "WatchedArtist" WHERE "lastDiscoveredAt" IS NULL')
assert cur.fetchone()[0] == 1 # seed due again