@johndimm/constellations 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johndimm/constellations",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "type": "module",
5
5
  "main": "./index.tsx",
6
6
  "exports": {
@@ -52,7 +52,6 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@google/genai": "^1.33.0",
55
- "@johndimm/constellations": "^1.0.2",
56
55
  "@types/d3": "^7.4.3",
57
56
  "d3": "^7.9.0",
58
57
  "dotenv": "^16.4.5",
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { getLlmProvider } from "./aiUtils";
7
7
  import * as geminiSvc from "./geminiService";
8
- import * as altSvc from "./deepseekService"; // handles deepseek, openai, anthropic
8
+ import * as altSvc from "./deepseekService"; // non-Gemini providers (deepseek / openai / anthropic)
9
9
 
10
10
  export * from "./aiUtils";
11
11
  export type { LockedPair } from "./geminiService";
@@ -386,7 +386,12 @@ export function setBrowserLlmOverride(provider: LlmProviderId | null): void {
386
386
  export function getBrowserLlmModel(): string | null {
387
387
  if (typeof window === "undefined") return null;
388
388
  try {
389
- return window.localStorage.getItem(BROWSER_LLM_MODEL_KEY)?.trim() || null;
389
+ const v = window.localStorage.getItem(BROWSER_LLM_MODEL_KEY)?.trim() || null;
390
+ if (v && getBrowserLlmOverride() === "anthropic" && isRetiredAnthropicModel(v)) {
391
+ window.localStorage.removeItem(BROWSER_LLM_MODEL_KEY);
392
+ return null;
393
+ }
394
+ return v;
390
395
  } catch {}
391
396
  return null;
392
397
  }
@@ -419,6 +424,31 @@ export function getServerLlmModelOverride(): string | null {
419
424
  return _serverLlmModelOverride;
420
425
  }
421
426
 
427
+ /** Default when no model is configured (claude-3-5-haiku-20241022 was retired). */
428
+ export const DEFAULT_ANTHROPIC_MODEL = "claude-haiku-4-5-20251001";
429
+
430
+ const RETIRED_ANTHROPIC_MODELS = new Set([
431
+ "claude-3-5-haiku-20241022",
432
+ "claude-3-5-haiku-20240307",
433
+ ]);
434
+
435
+ export function isRetiredAnthropicModel(model: string): boolean {
436
+ return RETIRED_ANTHROPIC_MODELS.has(model.trim());
437
+ }
438
+
439
+ /** Per-request override → env → default; maps retired snapshot ids to current Haiku. */
440
+ export function resolveAnthropicModel(): string {
441
+ const raw =
442
+ getServerLlmModelOverride()?.trim() ||
443
+ readBundledEnv("VITE_ANTHROPIC_MODEL")?.trim() ||
444
+ DEFAULT_ANTHROPIC_MODEL;
445
+ if (isRetiredAnthropicModel(raw)) {
446
+ console.warn(`[anthropic] replacing retired model "${raw}" with "${DEFAULT_ANTHROPIC_MODEL}"`);
447
+ return DEFAULT_ANTHROPIC_MODEL;
448
+ }
449
+ return raw;
450
+ }
451
+
422
452
  export function getLlmProvider(): LlmProviderId {
423
453
  if (_serverLlmOverride) return _serverLlmOverride;
424
454
  const browser = getBrowserLlmOverride();
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { GeminiResponse, PersonWorksResponse, PathResponse } from "../types";
3
- import { parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, readBundledEnv, getLlmProvider, looksLikePersonName, getServerLlmModelOverride } from "./aiUtils";
3
+ import { parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, readBundledEnv, getLlmProvider, looksLikePersonName, getServerLlmModelOverride, getBrowserLlmModel, resolveAnthropicModel } from "./aiUtils";
4
4
  import type { LockedPair } from "./geminiService";
5
5
 
6
6
  export type { LockedPair };
@@ -8,6 +8,10 @@ export type { LockedPair };
8
8
  const TIMEOUT_MS = 60000;
9
9
  const CLASSIFY_TIMEOUT_MS = 15000;
10
10
 
11
+ function llmLogTag(): string {
12
+ return `[${getLlmProvider()}]`;
13
+ }
14
+
11
15
  function shouldProxy(): boolean {
12
16
  if (typeof window === "undefined") return false;
13
17
  if ((window as any).__PRERENDER_INJECTED) return false;
@@ -20,7 +24,7 @@ async function callAiProxy(endpoint: string, body: any) {
20
24
  const resp = await fetch(url, {
21
25
  method: "POST",
22
26
  headers: { "Content-Type": "application/json" },
23
- body: JSON.stringify({ ...body, llmProvider: getLlmProvider() }),
27
+ body: JSON.stringify({ ...body, llmProvider: getLlmProvider(), llmModel: getBrowserLlmModel() ?? undefined }),
24
28
  });
25
29
  if (!resp.ok) throw new Error(`AI Proxy Error (${resp.status}): ${await resp.text()}`);
26
30
  return resp.json();
@@ -32,7 +36,7 @@ async function callAltLlm(system: string, user: string, timeoutMs = TIMEOUT_MS):
32
36
  if (provider === "anthropic") {
33
37
  const key = readBundledEnv("VITE_ANTHROPIC_API_KEY");
34
38
  if (!key) throw new Error("No VITE_ANTHROPIC_API_KEY set");
35
- const model = readBundledEnv("VITE_ANTHROPIC_MODEL") || "claude-3-5-haiku-20241022";
39
+ const model = resolveAnthropicModel();
36
40
  const res = await fetch("https://api.anthropic.com/v1/messages", {
37
41
  method: "POST",
38
42
  headers: {
@@ -217,7 +221,7 @@ Return JSON with exactly these fields:
217
221
  reasoning: s(json.reasoning, ""),
218
222
  };
219
223
  } catch (e) {
220
- console.warn("[DeepSeek] classifyStartPair failed:", String(e).slice(0, 200));
224
+ console.warn(`${llmLogTag()} classifyStartPair failed:`, String(e).slice(0, 200));
221
225
  return fallback;
222
226
  }
223
227
  };
@@ -270,7 +274,7 @@ Return JSON:
270
274
  reasoning: json.reasoning as string | undefined,
271
275
  };
272
276
  } catch (e) {
273
- console.warn("[DeepSeek] classifyEntity failed:", String(e).slice(0, 200));
277
+ console.warn(`${llmLogTag()} classifyEntity failed:`, String(e).slice(0, 200));
274
278
  return fallback;
275
279
  }
276
280
  };
@@ -340,7 +344,7 @@ Return JSON:
340
344
  .map(p => ({ ...p, isAtomic: true }));
341
345
  return parsed;
342
346
  } catch (e) {
343
- console.error("[DeepSeek] fetchConnections error:", e);
347
+ console.error(`${llmLogTag()} fetchConnections error:`, e);
344
348
  return { people: [] };
345
349
  }
346
350
  };
@@ -403,7 +407,7 @@ Return JSON:
403
407
  .map(w => ({ ...w, isAtomic: false }));
404
408
  return parsed;
405
409
  } catch (e) {
406
- console.error("[DeepSeek] fetchPersonWorks error:", e);
410
+ console.error(`${llmLogTag()} fetchPersonWorks error:`, e);
407
411
  return { works: [] };
408
412
  }
409
413
  };
@@ -444,7 +448,7 @@ Return JSON:
444
448
  if (!json || !Array.isArray(json.path)) return { path: [], found: false };
445
449
  return { path: json.path, found: json.path.length > 0 };
446
450
  } catch (e) {
447
- console.error("[DeepSeek] fetchConnectionPath error:", e);
451
+ console.error(`${llmLogTag()} fetchConnectionPath error:`, e);
448
452
  return { path: [], found: false };
449
453
  }
450
454
  };
@@ -173,17 +173,28 @@ export function defaultStartPairResult(reason: string, term?: string): {
173
173
  };
174
174
  }
175
175
 
176
- /** Messy pasted now-playing / YouTube text; short single-line queries skip the extra Gemini call. */
177
- function rawTermNeedsMusicEntityExtract(raw: string): boolean {
176
+ /** Messy pasted now-playing / YouTube text; also Soundings-style "Track (Artist)" / "Track by Artist". */
177
+ export function rawTermNeedsMusicEntityExtract(raw: string): boolean {
178
178
  const t = raw.trim();
179
179
  if (!t) return false;
180
180
  if (t.length > 220) return true;
181
181
  if (t.includes("\n")) return true;
182
182
  if (/https?:\/\/(www\.)?(youtube\.com|youtu\.be)\b/i.test(t)) return true;
183
183
  if (/\b(feat\.|ft\.|official\s+video|official\s+audio)\b/i.test(t)) return true;
184
+ // e.g. "Summertime (Miles Davis)" from now-playing bridge — bare title is ambiguous
185
+ if (/^.+\s+\([^)]{2,}\)\s*$/.test(t)) return true;
186
+ if (/^.+\s+by\s+.+$/i.test(t)) return true;
184
187
  return false;
185
188
  }
186
189
 
190
+ /** Normalize a graph seed before classify-start (music disambiguation). Used on server + local non-proxy paths. */
191
+ export async function resolveStartSearchTerm(raw: string): Promise<string> {
192
+ const trimmed = raw.trim();
193
+ if (!trimmed) return trimmed;
194
+ if (!rawTermNeedsMusicEntityExtract(trimmed)) return trimmed;
195
+ return extractMusicEntity(trimmed);
196
+ }
197
+
187
198
  /**
188
199
  * Given raw pasted text (YouTube title, channel name, multi-line description),
189
200
  * ask the LLM to pick the best graph starting node — using world knowledge to
@@ -295,11 +306,13 @@ export const classifyStartPair = async (
295
306
  return await callAiProxy("/api/ai/classify-start", { term: rawTerm.trim(), wikiContext });
296
307
  }
297
308
 
298
- const needsMusic = rawTermNeedsMusicEntityExtract(rawTerm);
309
+ const term = await resolveStartSearchTerm(rawTerm);
299
310
  if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
300
- console.log("[Constellations]", "classifyStartPair local Gemini path", { needsMusicExtract: needsMusic });
311
+ console.log("[Constellations]", "classifyStartPair local Gemini path", {
312
+ term: term.slice(0, 80),
313
+ resolvedFrom: term !== rawTerm.trim() ? rawTerm.trim().slice(0, 80) : undefined,
314
+ });
301
315
  }
302
- const term = needsMusic ? await extractMusicEntity(rawTerm) : rawTerm.trim();
303
316
 
304
317
  const apiKey = await getApiKey();
305
318
  // String-level safety heuristic (no Wikipedia required):
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  clipForLlmLog,
3
- getEnvVar,
4
3
  getLlmProvider,
5
4
  getLlmApiKey,
6
5
  getResponseText,
7
6
  isRateLimitError,
7
+ resolveAnthropicModel,
8
8
  withRetry,
9
9
  withTimeout,
10
10
  type LlmProviderId,
@@ -77,7 +77,7 @@ async function anthropicJson(system: string | undefined, user: string): Promise<
77
77
  const key = await getLlmApiKey();
78
78
  if (!key) throw new Error("No API key for anthropic");
79
79
 
80
- const model = getEnvVar("VITE_ANTHROPIC_MODEL") || "claude-3-5-haiku-20241022";
80
+ const model = resolveAnthropicModel();
81
81
 
82
82
  const res = await fetch("https://api.anthropic.com/v1/messages", {
83
83
  method: "POST",
@@ -8,6 +8,19 @@ export type NowPlayingSnapshot = {
8
8
  artist?: string | null;
9
9
  };
10
10
 
11
+ /** Append performer when a bare title is ambiguous (e.g. "Summertime" vs "In the Summertime"). */
12
+ function nowPlayingSearchTerm(track: string, artist: string): string {
13
+ const t = track.trim();
14
+ const a = artist.trim();
15
+ if (!t) return a;
16
+ if (!a) return t;
17
+ if (t.toLowerCase().includes(a.toLowerCase())) return t;
18
+ // Classical / rich titles already name the composer; YouTube channel is often junk.
19
+ if (/[:(~]/.test(t)) return t;
20
+ if (/\([^)]+\)\s*$/.test(t)) return t;
21
+ return `${t} (${a})`;
22
+ }
23
+
11
24
  /**
12
25
  * State sync for every full-page constellations host (Soundings, Trailer, etc.): URL `q` / `expand`,
13
26
  * optional handoff gating, and optional live player bridge (now-playing + external search).
@@ -72,27 +85,25 @@ export function useFullPageConstellationsHost(input: {
72
85
  const album = snap?.album?.trim();
73
86
  const track = snap?.track?.trim();
74
87
  const artist = snap?.artist?.trim();
88
+ const searchTerm = nowPlayingSearchTerm(track, artist);
75
89
  const mergedExpand = [
76
90
  ...extra,
77
91
  ...(album ? [album] : []),
78
- ...(track ? [track] : []),
79
- ...(artist ? [artist] : []),
92
+ ...(searchTerm ? [searchTerm] : []),
93
+ ...(track && searchTerm !== track ? [track] : []),
94
+ ...(artist && !searchTerm.toLowerCase().includes(artist.toLowerCase()) ? [artist] : []),
80
95
  ];
81
96
  if (album || track) {
82
- setNowPlayingKey(`${npRev}::${album || ""}::${track || ""}`);
97
+ setNowPlayingKey(`${npRev}::${album || ""}::${track || ""}::${artist || ""}`);
83
98
  } else {
84
99
  setNowPlayingKey(null);
85
100
  }
86
101
  if (qParam) {
87
102
  setExternalSearch(null);
88
103
  } else {
89
- // Prefer the track title over the artist/channel name. For YouTube classical music,
90
- // the title contains the composer ("Vaughan Williams ~ The Lark Ascending") while
91
- // the artist is just the uploader's channel name. The LLM in classifyStartPair
92
- // (extractMusicEntity) will parse the title to extract the primary musical entity.
93
- const searchTerm = track || snap?.artist?.trim() || "";
94
- if (searchTerm) {
95
- setExternalSearch({ term: searchTerm, id: `np:${searchTerm.toLowerCase()}` });
104
+ const term = searchTerm || artist || "";
105
+ if (term) {
106
+ setExternalSearch({ term, id: `np:${term.toLowerCase()}` });
96
107
  } else {
97
108
  setExternalSearch(null);
98
109
  }