mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 06:18:31 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb354f9c38 | ||
|
|
51085048aa | ||
|
|
117d260723 |
+27
-2
@@ -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))
|
||||||
|
|||||||
@@ -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 2–4): denoised Lucene query over /recording. The query
|
"""Text search (tier 2–4): 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) ──────────────
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user