feat: detect & link album mentions, not just artists (closes task/album-detection)
Source: task/album-detection (objective/linker-improvements) — project brief asks for artist OR album linking; MVP linked artists only.
- service/lib.js: pickAlbum (the exact-name gate, named for the album path) + linkResult now carries kind:"artist"|"album" (additive; defaults to artist so older clients keep working).
- service/resolver.js: one Spotify type=artist,album search per candidate, exact-match against either; returns {url,kind}; cache key includes the kind hint. resolveOne/resolveAll thread the LLM kind hint through.
- service/llm.js: parseClassification now returns Map(name -> "artist"|"album") so album-kind candidates route to the album path (.has() still works like the old Set).
- extension/extract.js: albumMatchesIn over two precise signals (quoted strings + album cue phrases "the album X", "X LP/EP"), folded into allMatchesIn.
- extension/content.js + styles.css: album links render italicised (.rsl-album) with an album tooltip; artist behavior unchanged.
- Tests: new pure-logic coverage in lib.test.js / llm.test.js / extract.test.cjs (album shaping, Map-kind routing, album candidate extraction). scripts/check.sh green.
- README.md + AGENTS.md: document album linking.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+38
-3
@@ -21,6 +21,14 @@
|
||||
// the `d` flag gives us the group's position so the match can be wrapped in place.
|
||||
const CUE_RE = /\b(?:by|check ?out|listen(?: to)?|recommend(?:ed|ations?)?|fan of|into|loves?|playing)\s+([A-Za-z][A-Za-z0-9'’.&-]*(?:\s+[A-Za-z0-9][A-Za-z0-9'’.&-]*)?)/gid;
|
||||
|
||||
// Albums are noisier free-text than artists, so only TWO precise signals generate album
|
||||
// candidates — the Spotify exact-album match (and/or LLM) is still the precision filter:
|
||||
// 1. A quoted string ("Reek of Putrefaction" or 'Scum') — a strong "this is a title" cue.
|
||||
// 2. An album cue phrase ("the album Scum", "Scum LP/EP") naming the next 1-4 words.
|
||||
// Group 1 of each holds the candidate; the `d` flag gives its position for in-place wrapping.
|
||||
const QUOTED_RE = /["“]([^"”]{2,80})["”]|['‘]([^'’]{2,80})['’]/gd;
|
||||
const ALBUM_CUE_RE = /\b(?:the )?(?:album|record|ep|lp)\s+(?:called\s+|titled\s+|named\s+)?([A-Za-z0-9][A-Za-z0-9'’.&\- ]{0,79}?)(?=[.,;:!?)\]"”'’]|\s+(?:is|was|by|and|on|from|which|that)\b|$)|\b([A-Za-z0-9][A-Za-z0-9'’.&\- ]{0,79}?)\s+(?:LP|EP)\b/gd;
|
||||
|
||||
// Common capitalized words that are almost never the artist being recommended.
|
||||
// Deliberately small: the resolver's exact-name match is the real filter.
|
||||
const STOP = new Set([
|
||||
@@ -76,13 +84,40 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
// Union of Title-Case + cue matches, sorted for the in-place wrapper
|
||||
// Album candidates from the two precise signals (quoted strings + album cue phrases).
|
||||
// Each match is { name, index, length } like the others; the server's exact Spotify
|
||||
// album match is what actually confirms it (this only over-generates the candidates).
|
||||
function albumMatchesIn(text) {
|
||||
const out = [];
|
||||
QUOTED_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = QUOTED_RE.exec(text))) {
|
||||
const gi = m.indices[1] || m.indices[2]; // double- or single-quoted group
|
||||
if (!gi) continue;
|
||||
const name = text.slice(gi[0], gi[1]).trim();
|
||||
if (name.length < 2 || /^\d+$/.test(name)) continue;
|
||||
// index/length cover the inner text only (no quotes), so the wrapper links the title.
|
||||
out.push({ name, index: gi[0], length: gi[1] - gi[0] });
|
||||
}
|
||||
ALBUM_CUE_RE.lastIndex = 0;
|
||||
while ((m = ALBUM_CUE_RE.exec(text))) {
|
||||
const gi = m.indices[1] || m.indices[2]; // "album X" group or "X LP/EP" group
|
||||
if (!gi) continue;
|
||||
const raw = text.slice(gi[0], gi[1]);
|
||||
const name = raw.replace(TRAILING_PUNCT, "").trim();
|
||||
if (name.length < 2 || /^\d+$/.test(name)) continue;
|
||||
out.push({ name, index: gi[0], length: name.length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Union of Title-Case + cue + album matches, sorted for the in-place wrapper
|
||||
// (ascending index, longer match first so the wrapper's overlap guard prefers it).
|
||||
function allMatchesIn(text) {
|
||||
const all = matchesIn(text).concat(cueMatchesIn(text));
|
||||
const all = matchesIn(text).concat(cueMatchesIn(text), albumMatchesIn(text));
|
||||
all.sort((a, b) => a.index - b.index || b.length - a.length);
|
||||
return all;
|
||||
}
|
||||
|
||||
return { matchesIn, cueMatchesIn, allMatchesIn };
|
||||
return { matchesIn, cueMatchesIn, albumMatchesIn, allMatchesIn };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user