mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-10-02 11:11:47 +00:00
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:
co-authored by
Claude Opus 4.8
parent
b6169af6aa
commit
117d260723
+19
-2
@@ -198,12 +198,29 @@ def _lucene_escape_phrase(s: str) -> str:
|
||||
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 —
|
||||
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)
|
||||
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 = []
|
||||
if t:
|
||||
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
||||
|
||||
@@ -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]:
|
||||
"""Text search (tier 2–4): denoised Lucene query over /recording."""
|
||||
"""Text search (tier 2–4): 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)
|
||||
if not query:
|
||||
return []
|
||||
body = _mb_http_get("recording", {"query": query, "limit": limit})
|
||||
return mb_match.parse_search_response(body or {})
|
||||
cands: list[dict] = []
|
||||
if query:
|
||||
body = _mb_http_get("recording", {"query": query, "limit": limit})
|
||||
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:
|
||||
|
||||
@@ -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) ──────────────────
|
||||
|
||||
def test_offline_default_skips_matching(server, monkeypatch):
|
||||
|
||||
@@ -160,6 +160,21 @@ def test_build_recording_query_escapes_and_handles_missing_artist():
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
MB_DOC = {
|
||||
|
||||
Reference in New Issue
Block a user