// 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":,' + '"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; }