diff --git a/furumi-agent/src/merge.rs b/furumi-agent/src/merge.rs index eec8105..25f6e5b 100644 --- a/furumi-agent/src/merge.rs +++ b/furumi-agent/src/merge.rs @@ -147,6 +147,27 @@ async fn merge_db( proposal: &MergeProposal, loser_ids: &[i64], ) -> anyhow::Result<()> { + // 0. Validate proposal — ensure winner and all album IDs belong to source artists + let source_ids: Vec = loser_ids.iter().copied() + .chain(std::iter::once(proposal.winner_artist_id)) + .collect(); + + // Verify winner_artist_id is one of the source artists + if !source_ids.contains(&proposal.winner_artist_id) { + anyhow::bail!( + "winner_artist_id {} is not among source artists {:?}", + proposal.winner_artist_id, source_ids + ); + } + + // Build set of valid album IDs (albums that actually belong to source artists) + let mut valid_album_ids = std::collections::HashSet::::new(); + for &src_id in &source_ids { + let rows: Vec<(i64,)> = sqlx::query_as("SELECT id FROM albums WHERE artist_id = $1") + .bind(src_id).fetch_all(&mut **tx).await?; + for (id,) in rows { valid_album_ids.insert(id); } + } + // 1. Rename winner artist to canonical name sqlx::query("UPDATE artists SET name = $2 WHERE id = $1") .bind(proposal.winner_artist_id) @@ -155,6 +176,15 @@ async fn merge_db( // 2. Process album mappings from the proposal for mapping in &proposal.album_mappings { + // Skip albums that don't belong to any source artist (LLM hallucinated IDs) + if !valid_album_ids.contains(&mapping.source_album_id) { + tracing::warn!( + album_id = mapping.source_album_id, + "Skipping album mapping: album does not belong to source artists" + ); + continue; + } + // Skip if source was already processed (idempotent retry support) let src_exists: (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)") .bind(mapping.source_album_id)