refactor: extract testable resolve-core + cover album routing; check.sh checks .cjs (closes task/resolver-orchestration-testable)
Source: task/resolver-orchestration-testable (plan/steward-linker-quality) — new album orchestration in resolver.js had zero node:test coverage. - Extract the pure album/artist orchestration into service/resolve-core.js (resolveAll/resolveOne/pickDirect/cacheKey) taking injected search/classify fns; resolver.js becomes a thin server wiring the real Spotify search + ollama classify. No behavior change. - Add service/resolver.test.js: self-titled artist+album tie-break, LLM-album -> Google fallback, kind-aware cache separation, plus gate/drop/error paths. - scripts/check.sh: node --check now also matches *.cjs (extract.test.cjs). - AGENTS.md Testing: note resolve-core coverage + the extract.test.cjs extractor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// Pure album/artist resolution orchestration, decoupled from the HTTP server and the live
|
||||
// Spotify/ollama clients so `node --test` can drive it with injected fakes. resolver.js wires
|
||||
// the real `search` (a type=artist,album Spotify lookup behind the exact-name gate) and `classify`
|
||||
// (the ollama LLM gate) into these functions; the cache-key logic, the kindHint tie-break
|
||||
// ordering, and the allowFallback gating live here as pure logic.
|
||||
|
||||
import { norm, linkResult } from "./lib.js";
|
||||
|
||||
// Cap on how many distinct candidates one resolveAll call considers.
|
||||
export const MAX_CANDIDATES = 100;
|
||||
|
||||
// Decide the single direct-link {url, kind} for a name from a search result, or null. `result`
|
||||
// is what an injected `search(name)` returns: { artist, album } where each is the exact-match
|
||||
// hit's external url (string) or null/absent. `kindHint` ("artist" | "album", from the LLM gate)
|
||||
// breaks the tie when BOTH an artist and an album match the same string (e.g. a self-titled
|
||||
// album): album-first when hinted "album", else artist-first (the artist-first MVP default).
|
||||
export function pickDirect(result, kindHint) {
|
||||
const artist = result?.artist ?? null;
|
||||
const album = result?.album ?? null;
|
||||
const order = kindHint === "album"
|
||||
? [["album", album], ["artist", artist]]
|
||||
: [["artist", artist], ["album", album]];
|
||||
for (const [kind, url] of order) {
|
||||
if (url) return { url, kind };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The cache key for a name's direct-link lookup. It includes the kind hint because the same
|
||||
// string can resolve to an artist OR an album depending on which the gate confirmed, so a
|
||||
// hint-blind key would cross-contaminate the two.
|
||||
export const cacheKey = (name, kindHint) => `${kindHint || "artist"}:${norm(name)}`;
|
||||
|
||||
// Resolve one name to its link object, or null. `search(name) -> { artist, album }` is injected
|
||||
// (the live Spotify lookup, or a fake). `cache` is a Map(cacheKey -> {url, kind}|null) memoizing
|
||||
// the search half; the link object is rebuilt per call so a name's result never depends on a
|
||||
// stale gate decision. allowFallback: when the gate already confirmed this name, a missing direct
|
||||
// link still yields a Google->Bandcamp fallback link (instead of null). kindHint biases the
|
||||
// lookup tie-break and the fallback link's kind; an actual exact hit's kind wins when there is one.
|
||||
export async function resolveOne(name, { search, cache, allowFallback, kindHint }) {
|
||||
if (!norm(name)) return null;
|
||||
try {
|
||||
const key = cacheKey(name, kindHint);
|
||||
let hit;
|
||||
if (cache && cache.has(key)) {
|
||||
hit = cache.get(key);
|
||||
} else {
|
||||
hit = pickDirect(await search(name, kindHint), kindHint);
|
||||
if (cache) cache.set(key, hit);
|
||||
}
|
||||
if (hit) return linkResult(name, hit.url, hit.kind); // direct Spotify page (artist or album)
|
||||
return allowFallback ? linkResult(name, null, kindHint) : null; // google fallback, or unconfirmed
|
||||
} catch (e) {
|
||||
console.error(`resolve "${name}": ${e.message}`); // transient -> caller sees null this time
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a batch of candidate names to a { name -> link|null } map. Dependencies are injected:
|
||||
// search(name, kindHint) -> { artist, album } (live Spotify lookup or a fake; required)
|
||||
// classify(names) -> Map(norm(name) -> "artist"|"album") | null (the optional LLM gate; when
|
||||
// it returns null — no gate, or LLM down — the Spotify exact-match IS the gate: only names
|
||||
// actually on Spotify link. When it returns a Map, names absent from it are dropped, names in
|
||||
// it may use the Google fallback when not on Spotify, and the mapped kind routes the lookup.)
|
||||
// cache Map for memoizing the search half (optional)
|
||||
export async function resolveAll(candidates, { search, classify, cache } = {}) {
|
||||
const uniq = [...new Set(candidates.map((c) => String(c).trim()).filter(Boolean))].slice(0, MAX_CANDIDATES);
|
||||
|
||||
let confirmed = null;
|
||||
if (classify && uniq.length) {
|
||||
try {
|
||||
confirmed = await classify(uniq);
|
||||
} catch (e) {
|
||||
console.error(`llm classify: ${e.message}`); // LLM down -> fall back to the Spotify gate
|
||||
confirmed = null;
|
||||
}
|
||||
}
|
||||
|
||||
const out = {};
|
||||
await Promise.all(
|
||||
uniq.map(async (name) => {
|
||||
if (confirmed && !confirmed.has(norm(name))) {
|
||||
out[name] = null; // LLM said this is not an artist/album
|
||||
return;
|
||||
}
|
||||
const kindHint = confirmed ? confirmed.get(norm(name)) : undefined;
|
||||
out[name] = await resolveOne(name, {
|
||||
search,
|
||||
cache,
|
||||
allowFallback: confirmed != null,
|
||||
kindHint,
|
||||
});
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user