From 7c28eddd47b17b16e7958c7e381b46bbb3bc0ae1 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 10 Jul 2026 18:54:15 +0200 Subject: [PATCH] fix: persist all found candidates and guard duplicate adapter names Co-Authored-By: Claude Opus 4.8 (1M context) --- worker/lyra_worker/pipeline.py | 17 ++++++++++++----- worker/tests/test_pipeline.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/worker/lyra_worker/pipeline.py b/worker/lyra_worker/pipeline.py index eb966bb..ee63857 100644 --- a/worker/lyra_worker/pipeline.py +++ b/worker/lyra_worker/pipeline.py @@ -1,8 +1,10 @@ +from dataclasses import replace from typing import Sequence import psycopg from lyra_worker.adapters.base import SourceAdapter +from lyra_worker.confidence import score_confidence from lyra_worker.quality import quality_class from lyra_worker.ranker import rank_candidates from lyra_worker.types import Candidate, MBTarget @@ -103,6 +105,10 @@ def run_pipeline( dest_root: str = "/music", ) -> None: """Real staged acquisition using source-agnostic adapters. Fakes in this plan.""" + names = [a.name for a in adapters] + if len(names) != len(set(names)): + raise ValueError(f"adapter names must be unique, got {names}") + # 1. intake _set_state(conn, job_id, "matching", "intake") target = _load_target(conn, job_id) @@ -116,16 +122,17 @@ def run_pipeline( conn.commit() return - # 2. match + # 2. match — search all adapters; score + persist EVERY candidate found _set_state(conn, job_id, "matching", "match") - candidates: list[Candidate] = [] + found: list[Candidate] = [] for adapter in adapters: - candidates.extend(adapter.search(target)) + for c in adapter.search(target): + found.append(replace(c, confidence=score_confidence(target, c))) + _persist_candidates(conn, job_id, found) # 3. rank _set_state(conn, job_id, "matched", "rank") - ranked = rank_candidates(target, candidates, min_confidence) - _persist_candidates(conn, job_id, ranked) + ranked = rank_candidates(target, found, min_confidence) if not ranked: _fail(conn, job_id, "no candidate above confidence threshold") return diff --git a/worker/tests/test_pipeline.py b/worker/tests/test_pipeline.py index 7252d3d..4ef265d 100644 --- a/worker/tests/test_pipeline.py +++ b/worker/tests/test_pipeline.py @@ -99,3 +99,32 @@ def test_dedupe_skips_already_in_library(conn): cur.execute('SELECT count(*) FROM "LibraryItem" WHERE artist = %s AND album = %s', ("Radiohead", "In Rainbows")) assert cur.fetchone()[0] == 1 # not duplicated + + +def test_below_threshold_candidates_are_persisted_for_diagnosis(conn): + from lyra_worker.adapters.fakes import FakeQobuz + from lyra_worker.types import MBTarget + + class WrongAlbumQobuz(FakeQobuz): + def search(self, target): + # returns a candidate for a different album -> scores below the gate + return super().search(MBTarget(artist=target.artist, album="Some Completely Unrelated Title")) + + job_id = insert_request(conn, artist="Radiohead", album="In Rainbows") + claim_next(conn) + run_pipeline(conn, job_id, [WrongAlbumQobuz()], dest_root="/tmp/lib") + + assert _job_state(conn, job_id)[0] == "needs_attention" + with conn.cursor() as cur: + cur.execute('SELECT count(*) FROM "Candidate" WHERE "jobId" = %s', (job_id,)) + assert cur.fetchone()[0] >= 1 # found-but-rejected candidate retained for diagnosis + + +def test_duplicate_adapter_names_raise(conn): + import pytest + from lyra_worker.adapters.fakes import FakeQobuz + + job_id = insert_request(conn, artist="Radiohead", album="In Rainbows") + claim_next(conn) + with pytest.raises(ValueError): + run_pipeline(conn, job_id, [FakeQobuz(), FakeQobuz()], dest_root="/tmp/lib")