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>
This commit is contained in:
ChrisBeWithYou
2026-07-04 15:03:26 -05:00
co-authored by Claude Opus 4.8
parent b6169af6aa
commit 117d260723
4 changed files with 91 additions and 7 deletions
+19 -2
View File
@@ -198,12 +198,29 @@ def _lucene_escape_phrase(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"') return s.replace("\\", "\\\\").replace('"', '\\"')
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.
return " AND ".join("(%s)" % g for g in (t, a) if g)
parts = [] parts = []
if t: if t:
parts.append('recording:"%s"' % _lucene_escape_phrase(t)) parts.append('recording:"%s"' % _lucene_escape_phrase(t))
+19 -4
View File
@@ -6236,12 +6236,27 @@ def _mb_http_get(path: str, params: dict) -> dict | None:
def _mb_search_recordings(artist, title, limit: int = 8) -> list[dict]: def _mb_search_recordings(artist, title, limit: int = 8) -> list[dict]:
"""Text search (tier 24): denoised Lucene query over /recording.""" """Text search (tier 24): denoised Lucene query over /recording.
Runs the strict field-phrase query first (high precision, unchanged); 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
def _mb_lookup_recording(mbid: str) -> dict | None: def _mb_lookup_recording(mbid: str) -> dict | None:
+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):
+15
View File
@@ -160,6 +160,21 @@ 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 (unchanged) locks to the *primary* artist/title phrase.
assert m.build_recording_query("Junko Ohashi", "Telephone Number") == \
'recording:"telephone number" AND artist:"junko ohashi"'
# The loose form has no field scoping and no phrases, so MusicBrainz also
# searches artist ALIASES — rescues non-Latin-primary artists (大橋純子).
loose = m.build_recording_query("Junko Ohashi", "Telephone Number", loose=True)
assert loose == "(telephone number) AND (junko ohashi)"
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)"
# ── MusicBrainz response parsing ────────────────────────────────────────────── # ── MusicBrainz response parsing ──────────────────────────────────────────────
MB_DOC = { MB_DOC = {