@johndimm/constellations 1.0.8 → 1.0.9

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.
@@ -134,10 +134,22 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
134
134
  // CRITICAL FIX: Only use kiosk domain context if the user hasn't provided a specific disambiguated term.
135
135
  // "Republic (Plato)" should NEVER get "Actors / Movies / TV" context.
136
136
  const hasDisambiguation = term.includes('(') && term.includes(')');
137
- const wikiContext = (showControlPanel && !hasDisambiguation) ? selectedKioskDomain?.label : typeHint;
137
+ const wikiContext = [
138
+ !hasDisambiguation && showControlPanel ? selectedKioskDomain?.label : null,
139
+ typeHint,
140
+ type,
141
+ ].filter(Boolean).join(" ") || undefined;
138
142
 
139
143
  const wiki = await fetchWikipediaSummary(term, wikiContext);
140
- const canonicalTitle = (wiki.title || term).trim();
144
+ let canonicalTitle = (wiki.title || term).trim();
145
+ const userFilmYear = term.match(/\((\d{4})\s*(?:film|movie|tv)/i)?.[1];
146
+ const wikiFilmYear = canonicalTitle.match(/\((\d{4})\s*(?:film|movie|tv)/i)?.[1];
147
+ if (userFilmYear && wikiFilmYear && userFilmYear !== wikiFilmYear) {
148
+ console.warn(
149
+ `[Constellations] Wikipedia year mismatch for "${term}": got "${canonicalTitle}", keeping user term`
150
+ );
151
+ canonicalTitle = term.trim();
152
+ }
141
153
 
142
154
  // We no longer rewrite the user's query to the Wikipedia title.
143
155
  // This ensures "Republic (book)" stays as "Republic (book)" in the UI.
@@ -158,6 +170,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
158
170
  x: dim.width / 2,
159
171
  y: dim.height / 2,
160
172
  expanded: false,
173
+ year: wiki.year ?? undefined,
161
174
  wikiSummary: wiki.extract || undefined,
162
175
  classification_reasoning: reasoning,
163
176
  atomic_type: chosenPair.atomicType,
package/host.ts CHANGED
@@ -14,3 +14,4 @@ export { useFullPageConstellationsHost } from "./useFullPageConstellationsHost";
14
14
  export type { NowPlayingSnapshot } from "./useFullPageConstellationsHost";
15
15
  export { FullPageConstellationsHostLoading } from "./FullPageConstellationsHostShell";
16
16
  export { newChannelFromGraphNode } from "./utils/graphNodeToChannelNotes";
17
+ export { filmWorkSearchTerm, extractYearFromFilmTitle } from "./utils/graphLogicUtils";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johndimm/constellations",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "type": "module",
5
5
  "main": "./index.tsx",
6
6
  "exports": {
@@ -1,18 +1,93 @@
1
+ /** Trim whitespace and optional wrapping quotes (common when pasting into Render/Vercel). */
2
+ export function normalizeEnvSecret(raw: string | undefined | null): string {
3
+ if (raw == null) return "";
4
+ let s = String(raw).trim().replace(/^\uFEFF/, "");
5
+ if (
6
+ (s.startsWith('"') && s.endsWith('"')) ||
7
+ (s.startsWith("'") && s.endsWith("'"))
8
+ ) {
9
+ s = s.slice(1, -1).trim();
10
+ }
11
+ if (s.toLowerCase().startsWith("bearer ")) s = s.slice(7).trim();
12
+ // API keys must not contain whitespace; a mid-key line break keeps the last 4 chars "correct".
13
+ if (/^sk-/i.test(s)) s = s.replace(/\s+/g, "");
14
+ return s;
15
+ }
16
+
17
+ /** Node cache server: read secrets from process.env only (never import.meta build artifacts). */
18
+ export function readServerEnv(...keys: string[]): string {
19
+ if (typeof process === "undefined" || !process.env) return "";
20
+ for (const key of keys) {
21
+ const v = normalizeEnvSecret(process.env[key]);
22
+ if (v) return v;
23
+ }
24
+ return "";
25
+ }
26
+
27
+ export function getOpenAiApiKey(): string {
28
+ if (typeof window === "undefined") {
29
+ return readServerEnv("VITE_OPENAI_API_KEY", "OPENAI_API_KEY");
30
+ }
31
+ return normalizeEnvSecret(readBundledEnv("VITE_OPENAI_API_KEY"));
32
+ }
33
+
34
+ /** OpenAI / OpenAI-compatible chat/completions request config (server-safe env reads). */
35
+ export function getOpenAiCompatConfig(): {
36
+ apiKey: string;
37
+ baseUrl: string;
38
+ headers: Record<string, string>;
39
+ } {
40
+ const apiKey = getOpenAiApiKey();
41
+ const baseUrlRaw =
42
+ typeof window === "undefined"
43
+ ? readServerEnv("VITE_OPENAI_BASE_URL", "OPENAI_BASE_URL")
44
+ : normalizeEnvSecret(readBundledEnv("VITE_OPENAI_BASE_URL"));
45
+ const baseUrl = (baseUrlRaw || "https://api.openai.com/v1").replace(/\/$/, "");
46
+ const headers: Record<string, string> = {
47
+ "Content-Type": "application/json",
48
+ Authorization: `Bearer ${apiKey}`,
49
+ };
50
+ const org = readServerEnv(
51
+ "VITE_OPENAI_ORG",
52
+ "OPENAI_ORG",
53
+ "VITE_OPENAI_ORGANIZATION",
54
+ "OPENAI_ORGANIZATION",
55
+ );
56
+ const project = readServerEnv("VITE_OPENAI_PROJECT", "OPENAI_PROJECT");
57
+ if (org) headers["OpenAI-Organization"] = org;
58
+ if (project) headers["OpenAI-Project"] = project;
59
+ return { apiKey, baseUrl, headers };
60
+ }
61
+
62
+ export function getDeepSeekApiKey(): string {
63
+ if (typeof window === "undefined") {
64
+ return readServerEnv("VITE_DEEPSEEK_API_KEY", "DEEPSEEK_API_KEY");
65
+ }
66
+ return normalizeEnvSecret(readBundledEnv("VITE_DEEPSEEK_API_KEY"));
67
+ }
68
+
69
+ export function getAnthropicApiKey(): string {
70
+ if (typeof window === "undefined") {
71
+ return readServerEnv("VITE_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY");
72
+ }
73
+ return normalizeEnvSecret(readBundledEnv("VITE_ANTHROPIC_API_KEY"));
74
+ }
75
+
1
76
  /** Read a Vite-style env var from process (e.g. Next.js) or import.meta (Vite). */
2
77
  export function readBundledEnv(key: string): string {
3
- const fromProcess = getEnvVar(key);
78
+ const fromProcess = normalizeEnvSecret(getEnvVar(key));
4
79
  if (fromProcess) return fromProcess;
5
80
  // Dual-bundler: Vite uses `VITE_*`; Next exposes the same values as `NEXT_PUBLIC_VITE_*`
6
81
  // (see apps/soundings/next.config `env`), not `NEXT_PUBLIC_*` with the `VITE_` infix stripped.
7
82
  const nextKey = key.startsWith("VITE_") ? `NEXT_PUBLIC_${key}` : key;
8
- const alt = getEnvVar(nextKey);
83
+ const alt = normalizeEnvSecret(getEnvVar(nextKey));
9
84
  if (alt) return alt;
10
85
  try {
11
86
  // @ts-ignore
12
87
  if (typeof import.meta !== "undefined" && import.meta.env) {
13
88
  // @ts-ignore
14
89
  const v = import.meta.env[key];
15
- if (v != null && String(v) !== "") return String(v);
90
+ if (v != null && String(v) !== "") return normalizeEnvSecret(String(v));
16
91
  }
17
92
  } catch {
18
93
  /* ignore */
@@ -25,17 +100,20 @@ export const getEnvVar = (name: string): string => {
25
100
  try {
26
101
  if (typeof process !== 'undefined' && process.env) {
27
102
  const val = process.env[name];
28
- if (val) return val;
103
+ if (val != null && val !== "") return normalizeEnvSecret(val);
29
104
  }
30
105
  } catch (e) { }
31
106
 
107
+ // On the cache server, never fall back to import.meta (can embed stale client build keys).
108
+ if (typeof window === "undefined") return "";
109
+
32
110
  // Try import.meta.env (Vite / Browser)
33
111
  try {
34
112
  // @ts-ignore
35
113
  if (typeof import.meta !== 'undefined' && import.meta.env) {
36
114
  // @ts-ignore
37
115
  const val = import.meta.env[name];
38
- if (val) return val;
116
+ if (val != null && val !== "") return normalizeEnvSecret(String(val));
39
117
  }
40
118
  } catch (e) { }
41
119
 
@@ -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, getBrowserLlmModel, resolveAnthropicModel } from "./aiUtils";
3
+ import { parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, readBundledEnv, readServerEnv, getLlmProvider, looksLikePersonName, getServerLlmModelOverride, getBrowserLlmModel, resolveAnthropicModel, getOpenAiCompatConfig, getDeepSeekApiKey, getAnthropicApiKey } from "./aiUtils";
4
4
  import type { LockedPair } from "./geminiService";
5
5
 
6
6
  export type { LockedPair };
@@ -8,6 +8,15 @@ export type { LockedPair };
8
8
  const TIMEOUT_MS = 60000;
9
9
  const CLASSIFY_TIMEOUT_MS = 15000;
10
10
 
11
+ let loggedProviderKeyDiag = new Set<string>();
12
+
13
+ function logProviderKeyOnce(provider: string, key: string) {
14
+ if (typeof window !== "undefined") return;
15
+ if (!key || loggedProviderKeyDiag.has(provider)) return;
16
+ loggedProviderKeyDiag.add(provider);
17
+ console.log(`[${provider}] API key loaded (…${key.slice(-4)}, len=${key.length})`);
18
+ }
19
+
11
20
  function llmLogTag(): string {
12
21
  return `[${getLlmProvider()}]`;
13
22
  }
@@ -34,8 +43,9 @@ async function callAltLlm(system: string, user: string, timeoutMs = TIMEOUT_MS):
34
43
  const provider = getLlmProvider();
35
44
 
36
45
  if (provider === "anthropic") {
37
- const key = readBundledEnv("VITE_ANTHROPIC_API_KEY");
38
- if (!key) throw new Error("No VITE_ANTHROPIC_API_KEY set");
46
+ const key = getAnthropicApiKey();
47
+ if (!key) throw new Error("No VITE_ANTHROPIC_API_KEY or ANTHROPIC_API_KEY set");
48
+ logProviderKeyOnce("anthropic", key);
39
49
  const model = resolveAnthropicModel();
40
50
  const res = await fetch("https://api.anthropic.com/v1/messages", {
41
51
  method: "POST",
@@ -62,20 +72,30 @@ async function callAltLlm(system: string, user: string, timeoutMs = TIMEOUT_MS):
62
72
 
63
73
  // OpenAI-compatible: openai or deepseek
64
74
  const isOpenAI = provider === "openai";
75
+ const openAi = isOpenAI ? getOpenAiCompatConfig() : null;
65
76
  const baseUrl = isOpenAI
66
- ? (readBundledEnv("VITE_OPENAI_BASE_URL") || "https://api.openai.com/v1")
67
- : (readBundledEnv("VITE_DEEPSEEK_BASE_URL") || "https://api.deepseek.com/v1");
77
+ ? openAi!.baseUrl
78
+ : (typeof window === "undefined"
79
+ ? readServerEnv("VITE_DEEPSEEK_BASE_URL", "DEEPSEEK_BASE_URL")
80
+ : readBundledEnv("VITE_DEEPSEEK_BASE_URL")) || "https://api.deepseek.com/v1";
68
81
  const model = getServerLlmModelOverride() || (isOpenAI
69
- ? (readBundledEnv("VITE_OPENAI_MODEL") || "gpt-4o-mini")
70
- : (readBundledEnv("VITE_DEEPSEEK_MODEL") || "deepseek-chat"));
71
- const key = isOpenAI
72
- ? readBundledEnv("VITE_OPENAI_API_KEY")
73
- : readBundledEnv("VITE_DEEPSEEK_API_KEY");
82
+ ? (typeof window === "undefined"
83
+ ? readServerEnv("VITE_OPENAI_MODEL", "OPENAI_MODEL")
84
+ : readBundledEnv("VITE_OPENAI_MODEL")) || "gpt-4o-mini"
85
+ : (typeof window === "undefined"
86
+ ? readServerEnv("VITE_DEEPSEEK_MODEL", "DEEPSEEK_MODEL")
87
+ : readBundledEnv("VITE_DEEPSEEK_MODEL")) || "deepseek-chat");
88
+ const key = isOpenAI ? openAi!.apiKey : getDeepSeekApiKey();
74
89
  if (!key) throw new Error(`No API key set for ${provider}`);
90
+ logProviderKeyOnce(provider, key);
91
+
92
+ const headers = isOpenAI
93
+ ? { ...openAi!.headers }
94
+ : { "Content-Type": "application/json", Authorization: `Bearer ${key}` };
75
95
 
76
96
  const res = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
77
97
  method: "POST",
78
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
98
+ headers,
79
99
  body: JSON.stringify({
80
100
  model,
81
101
  messages: [
@@ -87,6 +107,16 @@ async function callAltLlm(system: string, user: string, timeoutMs = TIMEOUT_MS):
87
107
  });
88
108
  if (!res.ok) {
89
109
  const err = await res.text();
110
+ if (res.status === 401 && isOpenAI) {
111
+ console.error("[openai] 401 — check key is active, billing, and base URL", {
112
+ baseUrl,
113
+ model,
114
+ keyLen: key.length,
115
+ keySuffix: key.slice(-4),
116
+ hasOpenAIOrganization: !!headers["OpenAI-Organization"],
117
+ hasOpenAIProject: !!headers["OpenAI-Project"],
118
+ });
119
+ }
90
120
  throw new Error(`${provider} API error (${res.status}): ${err}`);
91
121
  }
92
122
  const data = await res.json();
@@ -653,6 +653,10 @@ export const fetchWikipediaSummary = async (
653
653
  const baseQuery = query.replace(/\s*\(.*\)\s*/g, '').trim();
654
654
  const parenMatch = query.match(/\((.*)\)/);
655
655
  const paren = parenMatch ? parenMatch[1] : null;
656
+ const queryYear =
657
+ (paren || cleanQuery).match(/(\d{4})/)?.[1] ||
658
+ (context || "").match(/\b(18|19|20)\d{2}\b/)?.[0] ||
659
+ null;
656
660
 
657
661
  // We search for the base query but include the parenthetical as additional context
658
662
  // This is more robust than a literal search for "Republic (book)" which ranks partial matches poorly.
@@ -703,6 +707,14 @@ export const fetchWikipediaSummary = async (
703
707
  if (snippet.includes(parenLower)) s += 200;
704
708
  }
705
709
 
710
+ // Film/TV year disambiguation: "Django" + 1966 must not become Django (2017 film).
711
+ if (queryYear) {
712
+ const titleYear = title.match(/\((\d{4})\s*(?:film|movie|tv)/i)?.[1];
713
+ if (titleYear === queryYear) s += 5000;
714
+ else if (titleYear && titleYear !== queryYear) s -= 5000;
715
+ if (snippet.includes(queryYear)) s += 400;
716
+ }
717
+
706
718
  // Music disambiguation: prefer musician/band pages over generic title definitions.
707
719
  const musicCtx = contextIndicatesMusic(context);
708
720
  const bizCtx = contextIndicatesBusiness(context);
@@ -9,7 +9,7 @@ export type NowPlayingSnapshot = {
9
9
  };
10
10
 
11
11
  /** Append performer when a bare title is ambiguous (e.g. "Summertime" vs "In the Summertime"). */
12
- function nowPlayingSearchTerm(track?: string, artist?: string): string {
12
+ function nowPlayingSearchTerm(track: string | undefined, artist: string | undefined): string {
13
13
  const t = (track ?? "").trim();
14
14
  const a = (artist ?? "").trim();
15
15
  if (!t) return a;
@@ -85,7 +85,7 @@ export function useFullPageConstellationsHost(input: {
85
85
  const album = snap?.album?.trim();
86
86
  const track = snap?.track?.trim();
87
87
  const artist = snap?.artist?.trim();
88
- const searchTerm = nowPlayingSearchTerm(track, artist);
88
+ const searchTerm = nowPlayingSearchTerm(track ?? "", artist ?? "");
89
89
  const mergedExpand = [
90
90
  ...extra,
91
91
  ...(album ? [album] : []),
@@ -14,6 +14,36 @@ export const looksLikeScreenWork = (title: string, desc?: string) => {
14
14
  );
15
15
  };
16
16
 
17
+ /** Year from a Wikipedia-style title, e.g. "Django (1966 film)" → 1966 */
18
+ export function extractYearFromFilmTitle(title: string): number | null {
19
+ const paren = title.match(/\((\d{4})\s*(?:film|movie|tv)/i);
20
+ if (paren) return parseInt(paren[1], 10);
21
+ const bare = title.match(/\b(18|19|20)\d{2}\b/);
22
+ return bare ? parseInt(bare[0], 10) : null;
23
+ }
24
+
25
+ /**
26
+ * Build a Wikipedia-friendly seed for films/TV (Trailer, etc.).
27
+ * "Django" + 1966 → "Django (1966 film)" so we don't land on Django (2017 film).
28
+ */
29
+ export function filmWorkSearchTerm(
30
+ title: string,
31
+ year?: number | null,
32
+ type?: string | null
33
+ ): string {
34
+ const t = title.replace(/\s+/g, " ").trim();
35
+ if (!t) return t;
36
+ if (/\(\d{4}\s*(?:film|movie|tv)/i.test(t)) return t;
37
+ const y = year ?? extractYearFromFilmTitle(t);
38
+ if (!y) return t;
39
+ const base = t.replace(/\s*\([^)]*\)\s*$/, "").trim() || t;
40
+ const kind =
41
+ type === "tv" || /\b(tv series|television)\b/i.test(String(type || ""))
42
+ ? "TV series"
43
+ : "film";
44
+ return `${base} (${y} ${kind})`;
45
+ }
46
+
17
47
  export const isBadListPage = (t?: string) => {
18
48
  const s = String(t || '').toLowerCase();
19
49
  if (!s) return false;