fix: tighten album cue extraction so candidates are title-shaped, not sentence fragments (closes task/album-cue-precision)

Source: task/album-cue-precision (plan/steward-linker-design) — album cue regexes over-generated junk candidates (no left anchors), feeding noise to the resolver/LLM and risking fallback mis-links.

- X LP/EP branch: anchor the capture to a 1-4 capitalized/quoted title run (mirrors CAND_RE) so 'I think their new LP' no longer yields the sentence prefix.
- 'the album X' branch: require the name to start with a capital/quote and reject a leading verb/article (is|was|by|the|a|an) so 'the album by Carcass' / 'the album is great' yield no candidate.
- QUOTED_RE: reject prose quotes — a span with mid-string sentence punctuation (. ! ?), over ~6 words, or a lone stopword interjection ('lol') — so prose quotes never become album candidates.
- Preserve position mapping (trim a captured leading quote and shift the index) and dedupe identical spans across the two signals; recall of real titles ('Reign in Blood', 'Human Jerky') unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
paul
2026-06-23 04:30:22 +00:00
co-authored by Claude Opus 4.8
parent 7adb99231a
commit a35dd95c88
2 changed files with 79 additions and 6 deletions
+42 -6
View File
@@ -23,11 +23,28 @@
// 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.
// 1. A quoted string ("Reek of Putrefaction" or 'Scum') — a strong "this is a title" cue,
// but only when it reads like a title (short, no sentence-ending punctuation mid-span),
// so prose quotes ("We hold these truths…", "lol") don't become candidates.
// 2. An album cue phrase ("the album Scum", "Scum LP/EP") naming a title-shaped run: the
// capture starts at a capitalized/quoted token (so a cue followed by a verb/article —
// "the album by Carcass", "the album is great" — yields no candidate) and the LP/EP
// branch is anchored to 1-4 capitalized words instead of grabbing the sentence prefix.
// 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;
// A title-shaped run for the LP/EP branch: 1-4 tokens starting at a capital/quote, mirroring
// CAND_RE's connector set so multi-word titles ("Reign in Blood") survive. Bounded ({0,3}).
const TITLE_RUN = `["“']?${TOKEN}(?:[ ]+(?:${TOKEN}|of|the|and|&|in|on|to|for|a|an)){0,3}["”']?`;
const ALBUM_CUE_RE = new RegExp(
// "the album X" — capture must START with a capital or opening quote so a cue immediately
// followed by a verb/article ("the album is great", "the album by Carcass") captures nothing.
`\\b(?:the )?(?:album|record|ep|lp)\\s+(?:called\\s+|titled\\s+|named\\s+)?([A-Z"'“‘][A-Za-z0-9'.&\\- ]{0,79}?)(?=[.,;:!?)\\]"”']|\\s+(?:is|was|by|and|on|from|which|that)\\b|$)` +
// "X LP/EP" — anchored title run (no left-anchor-less prefix grab).
`|\\b(${TITLE_RUN})\\s+(?:LP|EP)\\b`,
"gd"
);
// First word of an album-cue capture that, despite a leading capital, is not a title.
const ALBUM_LEAD_STOP = new Set(["is", "was", "by", "the", "a", "an"]);
// Common capitalized words that are almost never the artist being recommended.
// Deliberately small: the resolver's exact-name match is the real filter.
@@ -89,24 +106,43 @@
// album match is what actually confirms it (this only over-generates the candidates).
function albumMatchesIn(text) {
const out = [];
const seen = new Set(); // dedupe identical (index,length) spans across the two signals
const push = (name, index, length) => {
const key = index + ":" + length;
if (seen.has(key)) return;
seen.add(key);
out.push({ name, index, length });
};
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();
const words = name.split(/\s+/);
if (name.length < 2 || /^\d+$/.test(name)) continue;
// Reject prose quotes: a title doesn't carry sentence-ending punctuation mid-span and
// won't run long, and a lone interjection ("lol", "yeah") is a stopword, not a title.
if (/[.!?]/.test(name.slice(0, -1)) || words.length > 6) continue;
if (words.length === 1 && STOP.has(name[0].toUpperCase() + name.slice(1).toLowerCase())) 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] });
push(name, gi[0], 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]);
// Trim a leading opening-quote the title run may have captured, shifting the index so
// the emitted span still maps back to the source text; then strip trailing punct/quotes.
let start = gi[0];
let raw = text.slice(start, gi[1]);
const lead = raw.match(/^["“']+/);
if (lead) { start += lead[0].length; raw = raw.slice(lead[0].length); }
const name = raw.replace(TRAILING_PUNCT, "").trim();
if (name.length < 2 || /^\d+$/.test(name)) continue;
out.push({ name, index: gi[0], length: name.length });
// A capital-led capture that is actually a verb/article ("Is great", "By Carcass") is no title.
if (ALBUM_LEAD_STOP.has(name.split(/\s+/)[0].toLowerCase())) continue;
push(name, start, name.length);
}
return out;
}