Files
paulandClaude Opus 4.8 2b4babf955 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>
2026-06-22 22:33:32 +00:00

54 lines
2.7 KiB
JavaScript

// Pure helpers for the OPTIONAL local-LLM (ollama) classifier. The HTTP call lives in
// resolver.js; the prompt builder + reply parser live here so they unit-test with
// `node --test` (no network). The LLM answers one question: "which of these candidate
// substrings are music artists/albums?" — the precision gate when it is configured.
import { norm } from "./lib.js";
// Small, fast, runs comfortably on a 16GB M2 (3.1B params, Q4 ~2GB). Override with OLLAMA_MODEL.
export const DEFAULT_MODEL = "qwen2.5:3b";
// Chat messages for an ollama /api/chat call (used with stream:false, format:"json").
export function buildClassifyMessages(candidates) {
const system =
"You are a music-name classifier for a tool that links artist/album mentions in Reddit " +
"music discussions. You are given a JSON array of candidate text snippets pulled from reddit " +
"comments. For EACH candidate decide whether it names a music ARTIST/BAND, a music " +
"ALBUM/release, or NEITHER. The snippets come from music threads, so prefer the music reading " +
"when a candidate is a plausible band/album even if it is also an ordinary word. Mark ordinary " +
"words, sentence fragments, generic phrases, place names, and non-music proper nouns as \"none\". " +
'Reply with ONLY a JSON object of the form {"results":[{"name":<candidate verbatim>,' +
'"kind":"artist"|"album"|"none"}]}. Use each candidate string VERBATIM as its name.';
const user = JSON.stringify({ candidates });
return [
{ role: "system", content: system },
{ role: "user", content: user },
];
}
// Parse the model's JSON reply into a Map of norm(name) -> "artist" | "album" for the
// candidates the model marked as music (the kind routes each to the artist or album Spotify
// lookup). Defensive: only names that were actually in the candidate list count (guards
// against the model inventing names); an unparseable reply yields an empty Map, and the
// caller then falls back to the Spotify-exact-match gate. A Map still answers `.has()` like
// the old Set, so a name absent from it is dropped exactly as before.
export function parseClassification(text, candidates) {
const allowed = new Set(candidates.map(norm));
const confirmed = new Map();
let obj;
try {
obj = JSON.parse(text);
} catch {
return confirmed;
}
const rows = Array.isArray(obj?.results) ? obj.results : Array.isArray(obj) ? obj : [];
for (const row of rows) {
if (!row || typeof row.name !== "string") continue;
const k = norm(row.name);
if (!allowed.has(k)) continue;
const kind = String(row.kind ?? "").toLowerCase();
if (kind === "artist" || kind === "album") confirmed.set(k, kind);
}
return confirmed;
}