Compare commits

...
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 fb354f9c38 fix(enrichment): keep live exclusion in the loose search fallback
The loose fallback dropped the strict path's -secondarytype:Live filter, so a
studio chart whose strict query missed could fall back to — and, since
score_candidate doesn't penalize live takes, auto-confirm — a live-only
recording. Apply the same live gate to the loose query (skipped only when the
source title is itself a live take).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 01:04:52 +02:00
byrongamatos 51085048aa Merge remote-tracking branch 'origin/main' into feat/mb-loose-search-fallback
# Conflicts:
#	lib/mb_match.py
#	server.py
2026-07-05 00:30:04 +02:00
ChrisBeWithYouandClaude Opus 4.8 117d260723 feat(enrichment): loose MusicBrainz search fallback (find aliased artists)
The MB text search used a strict field-phrase query
(`recording:"<title>" AND artist:"<artist>"`). A field phrase only matches
MusicBrainz's *primary* artist/title — it never searches ALIASES — so a
recording stored under a non-Latin primary name (大橋純子) whose romanized
form ("Junko Ohashi") is only an alias returns ZERO results, even though MB
has it. Whole swaths of a community library (e.g. romanized J-pop / city-pop
charts) were unsearchable.

- `build_recording_query(..., loose=True)` drops the field scoping + phrases
  for plain AND-ed term groups (`(telephone number) AND (junko ohashi)`),
  which searches the whole document incl. aliases.
- `_mb_search_recordings` runs the strict query first (unchanged, high
  precision) and only on an EMPTY result retries once with the loose query —
  so mainstream matches are untouched and the extra throttled request is spent
  only on a miss. Results are re-scored by rank_candidates, so recall goes up
  without lowering match quality (auto-accept still needs the per-field floors).

Verified live: "Junko Ohashi / Telephone Number" and "Anri / Windy Summer"
(both 0 under the strict query) now surface the real records; "AC/DC /
Highway to Hell" still hits strict at score 1.0 with no loose retry.

Follow-up (separate): alias-aware SCORING so these can auto-confirm, not just
appear as manual candidates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:03:26 -05:00
4 changed files with 113 additions and 9 deletions
+27 -2
View File
@@ -234,12 +234,37 @@ def _lucene_escape_phrase(s: str) -> str:
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE) _LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
def build_recording_query(artist, title) -> str: def build_recording_query(artist, title, *, loose: bool = False) -> str:
"""Lucene query for /ws/2/recording. Built from the DENOISED fields — """Lucene query for /ws/2/recording. Built from the DENOISED fields —
the noise we strip (author credits, "(Live)", "(v2)") would otherwise the noise we strip (author credits, "(Live)", "(v2)") would otherwise
poison the search server's own scoring.""" poison the search server's own scoring.
``loose=True`` drops the field-scoped quoted PHRASES for plain AND-ed
term groups (``(telephone number) AND (junko ohashi)``). The point:
a field phrase like ``artist:"Junko Ohashi"`` only matches MusicBrainz's
*primary* artist name — it never searches ALIASES — so a recording stored
under a non-Latin primary (大橋純子) whose romanized name is only an alias
is invisible to the strict query. A loose term query searches the whole
document, aliases included, and surfaces it. Lower precision by design: it
is a FALLBACK for when the strict query returns nothing, and its results
are re-scored by ``rank_candidates`` (and, for auto-match, gated by the
per-field floors), so noise never auto-applies."""
t = denoise(title) t = denoise(title)
a = denoise(artist) a = denoise(artist)
if loose:
# denoise() already reduced each field to lowercase [a-z0-9 and] tokens
# (punctuation → spaces, diacritics stripped, & → "and"), so no
# Lucene-special character survives to need escaping. Group each field's
# terms and require both groups.
q = " AND ".join("(%s)" % g for g in (t, a) if g)
# Keep the SAME live exclusion as the strict path: the loose query is
# lower-precision, and score_candidate doesn't penalize a live take, so
# without this a studio chart whose strict query missed could fall back
# to — and auto-confirm — a live-only recording. Skipped only when the
# source title is itself a live take (mirrors the strict path).
if q and not _LIVE_GROUP_RE.search(str(title or "")):
q += " AND -secondarytype:Live"
return q
parts = [] parts = []
if t: if t:
parts.append('recording:"%s"' % _lucene_escape_phrase(t)) parts.append('recording:"%s"' % _lucene_escape_phrase(t))
+21 -6
View File
@@ -6320,15 +6320,30 @@ def _mb_http_get(path: str, params: dict) -> dict | None:
def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]: def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
"""Text search (tier 24): denoised Lucene query over /recording. The query """Text search (tier 24): denoised Lucene query over /recording. The strict
now drops live-only recordings and our ranker rewards the studio take, so a query drops live-only recordings and the ranker rewards the studio take, so a
slightly larger default result set gives the re-ranker room to surface the slightly larger default result set gives the re-ranker room to surface the
canonical version (one request per song regardless of limit).""" canonical version.
Runs the strict field-phrase query first (high precision); if it finds
nothing, retries ONCE with a loose term query. The strict phrase only matches
MusicBrainz's *primary* artist/title, so a recording stored under a non-Latin
primary name (大橋純子) whose romanized form ("Junko Ohashi") is only an alias
is invisible to it the loose query searches aliases and rescues it. The
retry spends a second throttled request only on a miss; results are re-scored
by rank_candidates, so the looser recall doesn't lower match quality
(auto-accept still needs the per-field floors)."""
query = mb_match.build_recording_query(artist, title) query = mb_match.build_recording_query(artist, title)
if not query: cands: list[dict] = []
return [] if query:
body = _mb_http_get("recording", {"query": query, "limit": limit}) body = _mb_http_get("recording", {"query": query, "limit": limit})
return mb_match.parse_search_response(body or {}) cands = mb_match.parse_search_response(body or {})
if not cands:
loose = mb_match.build_recording_query(artist, title, loose=True)
if loose and loose != query:
body = _mb_http_get("recording", {"query": loose, "limit": limit})
cands = mb_match.parse_search_response(body or {})
return cands
# ── AcoustID audio fingerprinting (content-based identification) ────────────── # ── AcoustID audio fingerprinting (content-based identification) ──────────────
+37
View File
@@ -97,6 +97,43 @@ def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1"
} }
# ── strict-then-loose search fallback ────────────────────────────────────────
def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
"""The strict field-phrase query misses a non-Latin-primary artist; the
loose retry (no field scoping) searches aliases and finds it."""
calls = []
def _routed(path, params):
q = params.get("query", "")
calls.append(q)
if q.startswith("recording:"): # strict phrase → nothing
return {"recordings": []}
return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]}
monkeypatch.setattr(server, "_mb_http_get", _routed)
cands = server._mb_search_recordings("Junko Ohashi", "Telephone Number")
assert len(cands) == 1
assert len(calls) == 2 # strict first, then the loose retry
assert calls[0].startswith("recording:") # strict is the field-phrase form
assert "artist:" not in calls[1] and '"' not in calls[1] # loose retry
def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
"""A strict hit must not spend a second (throttled) request on the loose
query."""
calls = []
def _routed(path, params):
calls.append(params.get("query", ""))
return {"recordings": [mb_doc()]}
monkeypatch.setattr(server, "_mb_http_get", _routed)
cands = server._mb_search_recordings("AC/DC", "Thunderstruck")
assert len(cands) == 1
assert len(calls) == 1
# ── offline safety (the pytest-never-hits-network contract) ────────────────── # ── offline safety (the pytest-never-hits-network contract) ──────────────────
def test_offline_default_skips_matching(server, monkeypatch): def test_offline_default_skips_matching(server, monkeypatch):
+27
View File
@@ -189,6 +189,33 @@ def test_build_recording_query_escapes_and_handles_missing_artist():
assert "artist:" not in q assert "artist:" not in q
def test_build_recording_query_loose_drops_field_phrases():
# The strict form locks to the *primary* artist/title phrase (and drops
# live-only recordings — the chart isn't a live take).
assert m.build_recording_query("Junko Ohashi", "Telephone Number") == \
'recording:"telephone number" AND artist:"junko ohashi" AND -secondarytype:Live'
# The loose form has no field scoping and no phrases, so MusicBrainz also
# searches artist ALIASES — rescues non-Latin-primary artists (大橋純子) —
# but keeps the same live exclusion (a studio chart must not fall back to a
# live-only recording).
loose = m.build_recording_query("Junko Ohashi", "Telephone Number", loose=True)
assert loose == "(telephone number) AND (junko ohashi) AND -secondarytype:Live"
assert "artist:" not in loose and '"' not in loose
def test_build_recording_query_loose_missing_artist():
assert m.build_recording_query("", "Fantasy", loose=True) == \
"(fantasy) AND -secondarytype:Live"
def test_build_recording_query_loose_keeps_live_for_live_charts():
# A live chart's loose fallback must NOT exclude live recordings (same gate
# as the strict path) — else its only correct recording is filtered out.
loose = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)", loose=True)
assert "-secondarytype:Live" not in loose
assert loose == "(highway to hell) AND (ac dc)"
# ── MusicBrainz response parsing ────────────────────────────────────────────── # ── MusicBrainz response parsing ──────────────────────────────────────────────
MB_DOC = { MB_DOC = {