Files
yellowjacket/backend/autotag/recommend.go
T
yonluandClaude Sonnet 5 cbd82a5a74
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
Autotag: detect "junk drawer" folders with no artist/album consensus
and split them into synthetic per-cluster groups instead of forcing
one match on an unrelated pile of tracks; repair tagging_items rows
left behind by a prior scan orphan-cleanup gap.

Explore: fix an exact artist-name search being drowned out by its own
catalog entries in intent-prior scoring, and prune stale in_library
bookkeeping left behind when a referenced library row is deleted.

Download: fix a multi-library regression where every import failed
with "no library root configured" — the importer resolved the
library root from a legacy single-library config field that nothing
populates in the current multi-library model. It now resolves the
destination library per-request from the request's own library_id.
Also widen the Soulseek search window (12s -> 20s), measured against
real request history to be missing available peers on live queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-10 11:52:26 -04:00

141 lines
4.2 KiB
Go

package autotag
// Recommendation is a qualitative confidence tier for a group's
// ranked candidates — the piece a raw score can't express on its
// own. Modeled on beets' Recommendation enum: the tier starts from
// the top candidate's absolute score and is then CAPPED by defects
// (ambiguity with a different release group, missing/unmatched
// tracks, thin evidence). Auto-accept (plan 011) should require
// RecommendationStrong; the review UI can badge the rest.
type Recommendation string
// Recommendation tiers, weakest to strongest.
const (
RecommendationNone Recommendation = "none"
RecommendationLow Recommendation = "low"
RecommendationMedium Recommendation = "medium"
RecommendationStrong Recommendation = "strong"
)
const (
// Absolute score tiers.
strongScoreThresh = 0.90
mediumScoreThresh = 0.75
// A runner-up from a DIFFERENT release group within this margin
// of the top score makes the match ambiguous — two genuinely
// different albums both fit, so a human should look. Editions
// of the same release group are expected to score nearly
// identically and never count as ambiguity.
ambiguityMargin = 0.05
)
// Recommend derives the confidence tier for a ranked candidate
// list. candidates must already be sorted best-first (the shape
// RankCandidates returns).
func Recommend(g Group, candidates []Candidate) Recommendation {
if len(candidates) == 0 {
return RecommendationNone
}
top := candidates[0]
var rec Recommendation
switch {
case top.Score >= strongScoreThresh:
rec = RecommendationStrong
case top.Score >= mediumScoreThresh:
rec = RecommendationMedium
default:
return RecommendationLow
}
// Cap: a different release group scoring within the ambiguity
// margin means the score alone can't pick between two albums.
if rivalWithinMargin(top, candidates[1:]) {
rec = minRecommendation(rec, RecommendationMedium)
}
// Cap: missing or unmatched tracks mean the alignment itself is
// incomplete, however good the matched tracks look (beets caps
// these penalties at "medium" the same way). A synthetic
// (tag-clustered) group is, by construction, a subset of a
// bigger folder, so AlignmentMissing (the candidate has tracks
// the group doesn't) is the expected shape rather than a defect
// and doesn't cap the recommendation. AlignmentUnmatched (the
// group has a track the candidate doesn't) is still a real
// discrepancy regardless of source.
for _, a := range top.Alignments {
if a.Status == AlignmentUnmatched ||
(a.Status == AlignmentMissing && !g.Synthetic) {
rec = minRecommendation(rec, RecommendationMedium)
break
}
}
// Cap: tiny folders can't corroborate a match strongly enough
// to act on without review, whatever the arithmetic says.
if len(g.Tracks) < evidenceFullTracks {
rec = minRecommendation(rec, RecommendationMedium)
}
return rec
}
// rivalWithinMargin reports whether any candidate from a different
// release group scores within ambiguityMargin of the top candidate.
func rivalWithinMargin(top Candidate, rest []Candidate) bool {
for _, c := range rest {
if top.Score-c.Score > ambiguityMargin {
// Sorted descending: everything further is farther away.
return false
}
if !sameReleaseGroup(top, c) {
return true
}
}
return false
}
// sameReleaseGroup reports whether two candidates belong to the
// same release group — by MBID when both carry one, by normalized
// title + artist-credit otherwise (local candidates may lack RG
// MBIDs).
func sameReleaseGroup(a, b Candidate) bool {
if a.ReleaseGroupMBID != "" && b.ReleaseGroupMBID != "" {
return a.ReleaseGroupMBID == b.ReleaseGroupMBID
}
return Normalize(a.Title) == Normalize(b.Title) &&
Normalize(a.ArtistCredit) == Normalize(b.ArtistCredit)
}
// recommendationRank orders tiers for min-comparison.
func recommendationRank(r Recommendation) int {
switch r {
case RecommendationNone:
return 0
case RecommendationLow:
return 1
case RecommendationMedium:
return 2
case RecommendationStrong:
return 3
default:
return 0
}
}
// minRecommendation returns the weaker of two tiers.
func minRecommendation(a, b Recommendation) Recommendation {
if recommendationRank(a) <= recommendationRank(b) {
return a
}
return b
}