@johndimm/constellations 1.0.7 → 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.
- package/hooks/useSearchHandlers.ts +15 -2
- package/host.ts +1 -0
- package/package.json +1 -1
- package/services/aiUtils.ts +83 -5
- package/services/deepseekService.ts +41 -11
- package/services/wikipediaService.ts +12 -0
- package/useFullPageConstellationsHost.ts +4 -4
- package/utils/graphLogicUtils.ts +30 -0
|
@@ -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 =
|
|
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
|
-
|
|
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
package/services/aiUtils.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
?
|
|
67
|
-
: (
|
|
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
|
-
? (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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,9 +9,9 @@ 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 {
|
|
13
|
-
const t = track.trim();
|
|
14
|
-
const a = artist.trim();
|
|
12
|
+
function nowPlayingSearchTerm(track: string | undefined, artist: string | undefined): string {
|
|
13
|
+
const t = (track ?? "").trim();
|
|
14
|
+
const a = (artist ?? "").trim();
|
|
15
15
|
if (!t) return a;
|
|
16
16
|
if (!a) return t;
|
|
17
17
|
if (t.toLowerCase().includes(a.toLowerCase())) return t;
|
|
@@ -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] : []),
|
package/utils/graphLogicUtils.ts
CHANGED
|
@@ -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;
|