fix: rebalance album cue recall — admit number/lowercase-led + abbreviation-period titles, bound 'the album X', strip curly quote (closes task/album-cue-recall-rebalance)
Source: task/album-cue-recall-rebalance (plan/steward-linker-design) — the a35dd95 precision tightening over-corrected, dropping real number/lowercase-led and abbreviation-period album titles.
- Loosen the album-cue lead token to admit a DIGIT (and, after an explicit
"the album X" cue, a lowercase-styled run), so number-led ("the album 1989",
"1989 LP", "36 Chambers") and lowercase-styled ("good kid maad city") titles
survive; ALBUM_LEAD_STOP still rejects an is/was/by/the/a/an lead.
- Narrow QUOTED_RE's mid-span reject to SENTENCE-ENDING punctuation only (a .!?
before whitespace/EOL, abbreviation periods exempted), so "M.A.A.D City",
"Mr. Bungle", "Sgt. Pepper's" survive while prose quotes still reject; raise
the word cap 6 → 8.
- Add the curly quotes ”“ (U+201D/U+201C) to TRAILING_PUNCT so an LP/EP run that
grabbed a trailing curly quote ("Reek of Putrefaction”") is trimmed, with the
span index/length kept so text.substr(index,length)===name still holds.
- Bound the "the album X" branch to the same 1-4-token run as the LP/EP branch,
so "the album Money Store dropped" yields "Money Store", not the sentence tail.
- Tests assert BOTH directions: recovered recall AND retained precision, plus the
curly-quote-trim position-mapping invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+54
-11
@@ -32,13 +32,24 @@
|
|||||||
// branch is anchored to 1-4 capitalized words instead of grabbing the sentence prefix.
|
// 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.
|
// 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 QUOTED_RE = /["“]([^"”]{2,80})["”]|['‘]([^'’]{2,80})['’]/gd;
|
||||||
// A title-shaped run for the LP/EP branch: 1-4 tokens starting at a capital/quote, mirroring
|
// A single word inside a title run (Title-Case/ALL-CAPS — or, for a digit-led run, a number).
|
||||||
// CAND_RE's connector set so multi-word titles ("Reign in Blood") survive. Bounded ({0,3}).
|
const WORD = "[A-Za-z0-9'’.&\\-]*";
|
||||||
const TITLE_RUN = `["“'‘]?${TOKEN}(?:[ ]+(?:${TOKEN}|of|the|and|&|in|on|to|for|a|an)){0,3}["”'’]?`;
|
// TITLE_RUN — a Title-Case-or-digit-led run of 1-4 tokens, mirroring CAND_RE's connector set so
|
||||||
|
// multi-word titles ("Reign in Blood") survive and bounded ({0,3}). Inner words are Title-Case
|
||||||
|
// tokens or connectors only, so trailing lowercase prose ("Money Store dropped" → "Money Store")
|
||||||
|
// is excluded. The lead also admits a DIGIT, so number-led titles ("1989 LP", "36 Chambers")
|
||||||
|
// survive; it does NOT admit a bare lowercase lead (keeps "their new LP" from leaking "new").
|
||||||
|
const TITLE_RUN = `["“'‘]?[A-Z0-9]${WORD}(?:[ ]+(?:${TOKEN}|of|the|and|&|in|on|to|for|a|an)){0,3}["”'’]?`;
|
||||||
|
// LOWER_RUN — a fully lowercase-styled title (lead + inner words all lowercase/digit), bounded to
|
||||||
|
// 1-4 words, for stylized titles after an explicit album cue ("good kid maad city", "untitled").
|
||||||
|
// A verb/article lead ("the album by Carcass", "the album is great") is captured here but then
|
||||||
|
// dropped by ALBUM_LEAD_STOP below, so this run never weakens the precision guard.
|
||||||
|
const LOWER_RUN = `[a-z0-9]${WORD}(?:[ ]+[a-z0-9]${WORD}){0,3}`;
|
||||||
const ALBUM_CUE_RE = new RegExp(
|
const ALBUM_CUE_RE = new RegExp(
|
||||||
// "the album X" — capture must START with a capital or opening quote so a cue immediately
|
// "the album X" — bounded to a 1-4-token run (TITLE_RUN for a capital/digit-led title, LOWER_RUN
|
||||||
// followed by a verb/article ("the album is great", "the album by Carcass") captures nothing.
|
// for a lowercase-styled one), so "the album Money Store dropped" yields "Money Store", not the
|
||||||
`\\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|$)` +
|
// sentence tail. A verb/article lead is dropped by ALBUM_LEAD_STOP below.
|
||||||
|
`\\b(?:the )?(?:album|record|ep|lp)\\s+(?:called\\s+|titled\\s+|named\\s+)?(${TITLE_RUN}|${LOWER_RUN})\\b` +
|
||||||
// "X LP/EP" — anchored title run (no left-anchor-less prefix grab).
|
// "X LP/EP" — anchored title run (no left-anchor-less prefix grab).
|
||||||
`|\\b(${TITLE_RUN})\\s+(?:LP|EP)\\b`,
|
`|\\b(${TITLE_RUN})\\s+(?:LP|EP)\\b`,
|
||||||
"gd"
|
"gd"
|
||||||
@@ -65,7 +76,32 @@
|
|||||||
"some", "any", "good", "best", "my", "your", "his", "their", "out", "to", "one", "all",
|
"some", "any", "good", "best", "my", "your", "his", "their", "out", "to", "one", "all",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TRAILING_PUNCT = /[.,;:!?’'")\]]+$/u;
|
// Trailing punctuation/quotes to strip off a captured run. Includes the curly close/open quotes
|
||||||
|
// (”“, U+201D/U+201C) so a title run that grabbed a trailing curly quote ("Reek of Putrefaction”")
|
||||||
|
// is trimmed symmetrically with the leading-quote trim; the span's index/length are adjusted to keep
|
||||||
|
// text.substr(index, length) === name.
|
||||||
|
const TRAILING_PUNCT = /[.,;:!?’'"”“)\]]+$/u;
|
||||||
|
|
||||||
|
// True when `s` carries SENTENCE-ENDING punctuation (a clause/sentence break), so it reads as
|
||||||
|
// prose, not a title. `!`/`?` followed by whitespace/end always count. A `.`+whitespace/end counts
|
||||||
|
// UNLESS it is an abbreviation period — a 1-3-letter alpha token directly before it ("Mr.", "Sgt.",
|
||||||
|
// "St.", initials) — which a title legitimately carries ("Mr. Bungle", "Sgt. Pepper's"). All regexes
|
||||||
|
// are linear (no nesting/backtracking) and run only over the short quoted span.
|
||||||
|
function isSentenceEnding(s) {
|
||||||
|
if (/[!?](\s|$)/.test(s)) return true; // ! / ? — never an abbreviation
|
||||||
|
let i = s.indexOf(".");
|
||||||
|
while (i !== -1) {
|
||||||
|
const after = s[i + 1];
|
||||||
|
if (after === undefined || /\s/.test(after)) {
|
||||||
|
// a period at end-of-string or before whitespace: sentence-ending unless it abbreviates a
|
||||||
|
// short alpha token (e.g. "Mr"/"Sgt"/"St"/"A") immediately to its left.
|
||||||
|
const m = s.slice(0, i).match(/([A-Za-z]+)$/);
|
||||||
|
if (!m || m[1].length > 3) return true;
|
||||||
|
}
|
||||||
|
i = s.indexOf(".", i + 1);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function matchesIn(text) {
|
function matchesIn(text) {
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -121,9 +157,13 @@
|
|||||||
const name = text.slice(gi[0], gi[1]).trim();
|
const name = text.slice(gi[0], gi[1]).trim();
|
||||||
const words = name.split(/\s+/);
|
const words = name.split(/\s+/);
|
||||||
if (name.length < 2 || /^\d+$/.test(name)) continue;
|
if (name.length < 2 || /^\d+$/.test(name)) continue;
|
||||||
// Reject prose quotes: a title doesn't carry sentence-ending punctuation mid-span and
|
// Reject prose quotes: a title doesn't carry SENTENCE-ENDING punctuation mid-span (a `.!?`
|
||||||
// won't run long, and a lone interjection ("lol", "yeah") is a stopword, not a title.
|
// FOLLOWED by whitespace or end-of-string — "We hold these truths…self-evident.") and won't
|
||||||
if (/[.!?]/.test(name.slice(0, -1)) || words.length > 6) continue;
|
// run long. An internal abbreviation period is NOT sentence-ending, so it survives — both the
|
||||||
|
// run-on-letter kind ("M.A.A.D City") and the short-token kind ("Mr. Bungle", "Sgt. Pepper's"),
|
||||||
|
// where a 1-3-char alpha token + period reads as an abbreviation, not a clause end. A lone
|
||||||
|
// interjection ("lol") is a stopword, not a title.
|
||||||
|
if (isSentenceEnding(name) || words.length > 8) continue;
|
||||||
if (words.length === 1 && STOP.has(name[0].toUpperCase() + name.slice(1).toLowerCase())) 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.
|
// index/length cover the inner text only (no quotes), so the wrapper links the title.
|
||||||
push(name, gi[0], gi[1] - gi[0]);
|
push(name, gi[0], gi[1] - gi[0]);
|
||||||
@@ -139,7 +179,10 @@
|
|||||||
const lead = raw.match(/^["“'‘]+/);
|
const lead = raw.match(/^["“'‘]+/);
|
||||||
if (lead) { start += lead[0].length; raw = raw.slice(lead[0].length); }
|
if (lead) { start += lead[0].length; raw = raw.slice(lead[0].length); }
|
||||||
const name = raw.replace(TRAILING_PUNCT, "").trim();
|
const name = raw.replace(TRAILING_PUNCT, "").trim();
|
||||||
if (name.length < 2 || /^\d+$/.test(name)) continue;
|
// Keep `length < 2`, but admit a pure-number title here (unlike the bare-quote branch): an
|
||||||
|
// explicit album cue ("the album 1989", "1989 LP") is the precision signal that a number is a
|
||||||
|
// title, not noise — so number-led albums survive.
|
||||||
|
if (name.length < 2) continue;
|
||||||
// A capital-led capture that is actually a verb/article ("Is great", "By Carcass") is no title.
|
// 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;
|
if (ALBUM_LEAD_STOP.has(name.split(/\s+/)[0].toLowerCase())) continue;
|
||||||
push(name, start, name.length);
|
push(name, start, name.length);
|
||||||
|
|||||||
@@ -101,6 +101,78 @@ test("albumMatchesIn: tightened cues still map positions back to the text", () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Recall rebalance: the a35dd95 precision tightening over-corrected. These assert the recovered
|
||||||
|
// recall (number/lowercase-led + abbreviation-period titles, bounded "the album X", no curly-quote
|
||||||
|
// leak) AND that the precision floor still holds (album path emits nothing on prose/filler). ---
|
||||||
|
|
||||||
|
test("albumMatchesIn: recall — number-led titles via an explicit album cue", () => {
|
||||||
|
// The cue ("the album" / "LP") is the precision signal that justifies a number as a title.
|
||||||
|
assert.ok(albumMatchesIn("the album 1989").map((m) => m.name).includes("1989"));
|
||||||
|
assert.ok(albumMatchesIn("1989 LP").map((m) => m.name).includes("1989"));
|
||||||
|
assert.ok(albumMatchesIn("the album 36 Chambers").map((m) => m.name).includes("36 Chambers"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("albumMatchesIn: recall — lowercase-styled titles after a cue", () => {
|
||||||
|
// good kid maad city is fully lowercase — a stylized title, not prose; the cue admits it.
|
||||||
|
assert.ok(
|
||||||
|
albumMatchesIn("the album good kid maad city").map((m) => m.name).includes("good kid maad city")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("albumMatchesIn: recall — internal abbreviation periods survive in a quoted title", () => {
|
||||||
|
// A `.` mid-span is only sentence-ending when it follows a long word; "M.A.A.D"/"Mr."/"Sgt." abbreviate.
|
||||||
|
assert.ok(albumMatchesIn('"M.A.A.D City"').map((m) => m.name).includes("M.A.A.D City"));
|
||||||
|
assert.ok(albumMatchesIn('"Mr. Bungle"').map((m) => m.name).includes("Mr. Bungle"));
|
||||||
|
assert.ok(
|
||||||
|
albumMatchesIn('"Sgt. Pepper\'s Lonely Hearts Club Band"')
|
||||||
|
.map((m) => m.name)
|
||||||
|
.includes("Sgt. Pepper's Lonely Hearts Club Band")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("albumMatchesIn: recall — curly close-quote is trimmed off an LP/EP title run", () => {
|
||||||
|
// Separate the title from the “…” so the trailing curly quote, not an artist possessive, is the target.
|
||||||
|
const text = "their “Reek of Putrefaction” LP";
|
||||||
|
const ms = albumMatchesIn(text);
|
||||||
|
const names = ms.map((m) => m.name);
|
||||||
|
assert.ok(names.includes("Reek of Putrefaction")); // exact string — no trailing ”
|
||||||
|
assert.ok(!names.some((n) => /[“”]/.test(n))); // no curly quote leaked into any candidate
|
||||||
|
// position mapping holds even after the leading-/trailing-quote trim shifts the span
|
||||||
|
for (const m of ms) assert.equal(text.substr(m.index, m.length), m.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("albumMatchesIn: recall — 'the album X' is bounded to the title, not the trailing prose", () => {
|
||||||
|
// a35dd95 left "the album X" an unbounded lazy run; it now reuses the 1-4-token bound.
|
||||||
|
const names = albumMatchesIn("the album Money Store dropped").map((m) => m.name);
|
||||||
|
assert.ok(names.includes("Money Store")); // the title
|
||||||
|
assert.ok(!names.includes("Money Store dropped")); // not the title + trailing verb
|
||||||
|
});
|
||||||
|
|
||||||
|
test("albumMatchesIn: precision — album path still emits nothing on prose / filler", () => {
|
||||||
|
// Each of these must yield NO album candidate (the precision guards a35dd95 added must survive).
|
||||||
|
assert.equal(albumMatchesIn("I think their new LP is amazing").length, 0); // lowercase filler before LP
|
||||||
|
assert.equal(albumMatchesIn("the album by Carcass").length, 0); // verb/article left edge
|
||||||
|
assert.equal(albumMatchesIn("the album is great but I forgot the name").length, 0); // "is" lead
|
||||||
|
assert.equal(albumMatchesIn('"We hold these truths to be self-evident."').length, 0); // prose quote
|
||||||
|
assert.equal(albumMatchesIn('"lol"').length, 0); // lone interjection
|
||||||
|
});
|
||||||
|
|
||||||
|
test("albumMatchesIn: every rebalance candidate maps back to the source text", () => {
|
||||||
|
// Position-mapping invariant across the recall cases, including the curly-quote-trim shift.
|
||||||
|
const texts = [
|
||||||
|
"the album 1989",
|
||||||
|
"1989 LP",
|
||||||
|
"the album good kid maad city",
|
||||||
|
'"M.A.A.D City"',
|
||||||
|
'"Mr. Bungle"',
|
||||||
|
"their “Reek of Putrefaction” LP",
|
||||||
|
"the album Money Store dropped",
|
||||||
|
];
|
||||||
|
for (const text of texts) {
|
||||||
|
for (const m of albumMatchesIn(text)) assert.equal(text.substr(m.index, m.length), m.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("isSafeHttpUrl accepts http/https and rejects javascript/data/garbage/empty", () => {
|
test("isSafeHttpUrl accepts http/https and rejects javascript/data/garbage/empty", () => {
|
||||||
// accepted: the only schemes a resolver link may inject as an href
|
// accepted: the only schemes a resolver link may inject as an href
|
||||||
assert.ok(isSafeHttpUrl("https://open.spotify.com/artist/abc"));
|
assert.ok(isSafeHttpUrl("https://open.spotify.com/artist/abc"));
|
||||||
|
|||||||
Reference in New Issue
Block a user