@johndimm/constellations 1.0.1 → 1.0.3

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.
Files changed (40) hide show
  1. package/App.tsx +360 -66
  2. package/FullPageConstellations.tsx +7 -4
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +67 -30
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +229 -250
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +2 -1
  10. package/components/NodeContextMenu.tsx +123 -3
  11. package/components/PeopleBrowserSidebar.tsx +15 -6
  12. package/components/Sidebar.tsx +46 -19
  13. package/components/TimelineView.tsx +1 -0
  14. package/hooks/useExpansion.ts +85 -230
  15. package/hooks/useGraphActions.ts +1 -0
  16. package/hooks/useGraphState.ts +75 -40
  17. package/hooks/useKioskMode.ts +1 -0
  18. package/hooks/useNodeClickHandler.ts +23 -15
  19. package/hooks/useSearchHandlers.ts +60 -21
  20. package/host.ts +1 -1
  21. package/index.css +17 -3
  22. package/index.tsx +5 -3
  23. package/package.json +4 -2
  24. package/services/aiService.ts +27 -0
  25. package/services/aiUtils.ts +285 -195
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +479 -0
  29. package/services/geminiService.ts +543 -736
  30. package/services/graphUtils.ts +128 -18
  31. package/services/imageService.ts +18 -0
  32. package/services/openAlexService.ts +1 -0
  33. package/services/resolveImageForTitle.ts +458 -0
  34. package/services/wikipediaImage.ts +1 -0
  35. package/services/wikipediaService.ts +79 -49
  36. package/sessionHandoff.ts +26 -0
  37. package/types.ts +3 -0
  38. package/utils/evidenceUtils.ts +1 -0
  39. package/utils/graphLogicUtils.ts +1 -0
  40. package/utils/wikiUtils.ts +14 -2
@@ -1,44 +1,9 @@
1
+ "use client";
1
2
  import { GoogleGenAI, Type } from "@google/genai";
2
- import { GeminiResponse, PersonWork, PersonWorksResponse, PathResponse } from "../types";
3
- import {
4
- clipForLlmLog,
5
- getApiKey,
6
- getResponseText,
7
- cleanJson,
8
- fetchWithTimeout,
9
- withTimeout,
10
- withRetry,
11
- getEnvCacheUrl,
12
- getEnvGeminiModel,
13
- getEnvGeminiModelClassify,
14
- getLlmApiKey,
15
- getLlmProvider,
16
- getBrowserLlmOverride,
17
- setBrowserLlmOverride,
18
- } from "./aiUtils";
19
- import { runJsonCompletion } from "./llmClient";
20
-
21
- function withProxyLlm(body: Record<string, unknown>) {
22
- const o = getBrowserLlmOverride();
23
- return o ? { ...body, llmProvider: o } : body;
24
- }
3
+ import { GeminiResponse, PersonWorksResponse, PathResponse } from "../types";
4
+ import { getApiKey, getResponseText, cleanJson, parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, getEnvGeminiModel, getEnvGeminiModelClassify, sanitizeSearchTerm, looksLikePersonName, getLlmProvider } from "./aiUtils";
25
5
 
26
- export {
27
- clipForLlmLog,
28
- getApiKey,
29
- getResponseText,
30
- cleanJson,
31
- fetchWithTimeout,
32
- withTimeout,
33
- withRetry,
34
- getEnvCacheUrl,
35
- getEnvGeminiModel,
36
- getEnvGeminiModelClassify,
37
- getLlmApiKey,
38
- getLlmProvider,
39
- getBrowserLlmOverride,
40
- setBrowserLlmOverride,
41
- } from "./aiUtils";
6
+ export { getApiKey, getResponseText, cleanJson, parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, getEnvGeminiModel, getEnvGeminiModelClassify } from "./aiUtils";
42
7
 
43
8
  const SYSTEM_INSTRUCTION = `
44
9
  You are a Bipartite Graph Generator.
@@ -64,6 +29,12 @@ CRITICAL EXAMPLES TO PREVENT MISCLASSIFICATION:
64
29
  - "Star Wars" → COMPOSITE (type: Movie, isAtomic: false), pair: Actor ↔ Movie
65
30
  - Movies/books/albums are ALWAYS composite (created BY actors/authors/musicians)
66
31
 
32
+ CLASSICAL MUSIC RULE:
33
+ - When a node title embeds a composer's name — patterns like "Bach: Goldberg Variations", "Ligeti — Lux Aeterna", "György Ligeti - Mathieu Romano / Lux Aeterna" — the COMPOSER is the primary atomic entity.
34
+ - Return the COMPOSER (e.g., "Johann Sebastian Bach", "György Ligeti") as an atomic node, NOT the performer/interpreter.
35
+ - The composition itself (e.g., "Goldberg Variations", "Lux Aeterna") is the composite node.
36
+ - Performers (e.g., "Glenn Gould", "Mathieu Romano") are secondary atomics; only include them if the composer is already present.
37
+
67
38
  CRITICAL ACCURACY RULE:
68
39
  If a section titled "USE THIS VERIFIED INFORMATION FOR ACCURACY" is provided, you MUST prioritize this information above your own internal knowledge.
69
40
 
@@ -85,14 +56,20 @@ Entity Classification:
85
56
  * Atomic entities (Actor, Person, Author, Artist, Character, Scientist, Philosopher, Academic, Researcher, Director, Composer) → isAtomic=true
86
57
  * Composite entities (Movie, Book, Novel, Play, Album, Band, Organization, Institution, Movement, Event, Company, Paper, Theory, Paradox) → isAtomic=false
87
58
 
59
+ CRITICAL — DO NOT return any of the following as entities:
60
+ - YouTube channel names or usernames (e.g. "pianetapapalla62", "France Musique concerts1899")
61
+ - Streaming platform names (YouTube, Spotify, France Musique, IDAGIO, Medici)
62
+ - Recording labels used alone without a work title (e.g. "Deutsche Grammophon", "ECM Records")
63
+ - Concert series or broadcast programme names that are not independently notable entities
64
+ - Any string that mixes a platform/channel name with digits (e.g. "concerts1899", "FMchannel42")
65
+ Only return canonical real-world named entities: composers, performers, compositions, musical works, ensembles, orchestras, or historical events.
66
+
88
67
  Return strict JSON.
89
68
  `;
90
69
 
91
70
  // Loosened timeouts to tolerate slower responses without failing immediately.
92
71
  const GEMINI_TIMEOUT_MS = 60000; // 60 seconds for heavier graph expansions
93
72
  const CLASSIFY_TIMEOUT_MS = 15000; // 15 seconds for classification
94
- /** Client wait for cache server (LLM + JSON) so the graph never spins forever on a hung proxy. */
95
- const PROXY_FETCH_TIMEOUT_MS = 120_000;
96
73
 
97
74
  // Model selection (configurable via Vite env vars)
98
75
  // - VITE_GEMINI_MODEL: used for expansions + pathfinding (default)
@@ -100,6 +77,21 @@ const PROXY_FETCH_TIMEOUT_MS = 120_000;
100
77
  const getGeminiModel = getEnvGeminiModel;
101
78
  const getGeminiModelClassify = getEnvGeminiModelClassify;
102
79
 
80
+ // Rejects YouTube channel names, streaming platforms, and other web junk.
81
+ function isValidEntityName(name: string): boolean {
82
+ if (!name || typeof name !== "string") return false;
83
+ const t = name.trim();
84
+ // Reject username pattern: no spaces, lowercase + digits, length > 4
85
+ if (!/\s/.test(t) && /[a-z]/.test(t) && /\d/.test(t) && t.length > 4) return false;
86
+ // Reject "! " separator common in YouTube video titles
87
+ if (t.includes("! ")) return false;
88
+ // Reject known streaming/platform keywords followed by digits
89
+ if (/\b(concerts?|channel|musique|archive|records?)\d+/i.test(t)) return false;
90
+ // Reject very long single-word strings
91
+ if (!/\s/.test(t) && t.length > 20) return false;
92
+ return true;
93
+ }
94
+
103
95
  export type LockedPair = {
104
96
  atomicType: string;
105
97
  compositeType: string;
@@ -110,99 +102,42 @@ async function callAiProxy(endpoint: string, body: any) {
110
102
  const baseUrl = getEnvCacheUrl();
111
103
  let resolvedBase = baseUrl;
112
104
 
113
- const url = new URL(
114
- endpoint,
115
- resolvedBase ||
116
- (typeof window !== "undefined" ? window.location.origin : ""),
117
- ).toString();
118
- const payload = withProxyLlm(body && typeof body === "object" && !Array.isArray(body) ? body : {});
105
+ const url = new URL(endpoint, resolvedBase || (typeof window !== 'undefined' ? window.location.origin : '')).toString();
106
+ if (
107
+ typeof window !== "undefined" &&
108
+ typeof process !== "undefined" &&
109
+ process.env.NODE_ENV === "development"
110
+ ) {
111
+ let uHost = "";
112
+ try {
113
+ uHost = new URL(url).host;
114
+ } catch {
115
+ uHost = "(bad url)";
116
+ }
117
+ console.log("[Constellations]", "AI proxy fetch", { endpoint, urlHost: uHost });
118
+ }
119
119
  try {
120
- console.info("[LLM] proxy REQUEST", endpoint, clipForLlmLog(JSON.stringify(payload)));
121
-
122
- const resp = await fetchWithTimeout(
123
- url,
124
- {
125
- method: "POST",
126
- headers: { "Content-Type": "application/json" },
127
- body: JSON.stringify(payload),
128
- },
129
- PROXY_FETCH_TIMEOUT_MS,
130
- );
120
+ const resp = await fetch(url, {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify({ ...body, llmProvider: getLlmProvider() })
124
+ });
131
125
 
132
126
  if (resp.status === 404 && endpoint === "/api/ai/classify-start") {
133
127
  // console.warn(`⚠️ [Proxy] ${endpoint} not found, falling back to /api/ai/classify`);
134
- return callAiProxy("/api/ai/classify", payload);
128
+ return callAiProxy("/api/ai/classify", body);
135
129
  }
136
130
 
137
131
  if (!resp.ok) {
138
132
  const err = await resp.text();
139
- const degraded =
140
- resp.status === 500 ||
141
- resp.status === 502 ||
142
- resp.status === 503 ||
143
- resp.status === 429;
144
- // Degrade gracefully when the cache server errors (quota, old deploy, etc.) so the UI keeps working.
145
- if (degraded && endpoint === "/api/ai/connections") {
146
- console.warn(
147
- `[Proxy] ${endpoint} ${resp.status}; returning empty people.`,
148
- err.slice(0, 300),
149
- );
150
- return { people: [] };
151
- }
152
- if (degraded && endpoint === "/api/ai/works") {
153
- console.warn(
154
- `[Proxy] ${endpoint} ${resp.status}; returning empty works.`,
155
- err.slice(0, 300),
156
- );
157
- return { works: [] };
158
- }
159
- if (degraded && endpoint === "/api/ai/path") {
160
- console.warn(
161
- `[Proxy] ${endpoint} ${resp.status}; returning empty path.`,
162
- err.slice(0, 300),
163
- );
164
- return { path: [], found: false };
165
- }
166
- if (degraded && endpoint === "/api/ai/classify-start") {
167
- console.warn(
168
- `[Proxy] ${endpoint} ${resp.status}; defaulting start pair.`,
169
- err.slice(0, 300),
170
- );
171
- return defaultStartPairResult(
172
- "Classification proxy error (quota or server); defaulting to Person↔Event.",
173
- );
174
- }
175
- if (degraded && endpoint === "/api/ai/classify") {
176
- console.warn(
177
- `[Proxy] ${endpoint} ${resp.status}; defaulting classify.`,
178
- err.slice(0, 300),
179
- );
180
- return { type: "Event", description: "", isAtomic: false };
181
- }
182
133
  throw new Error(`AI Proxy Error (${resp.status}): ${err}`);
183
134
  }
184
- const data = await resp.json();
185
- console.info("[LLM] proxy RESPONSE", endpoint, clipForLlmLog(JSON.stringify(data)));
186
- return data;
135
+ return resp.json();
187
136
  } catch (e: any) {
188
- const aborted = e?.name === "AbortError";
189
- if (aborted) {
190
- console.warn(`[Proxy] ${endpoint} timed out after ${PROXY_FETCH_TIMEOUT_MS}ms; degrading or rethrowing.`);
191
- if (endpoint === "/api/ai/connections") return { people: [] };
192
- if (endpoint === "/api/ai/works") return { works: [] };
193
- if (endpoint === "/api/ai/path") return { path: [], found: false };
194
- if (endpoint === "/api/ai/classify-start") {
195
- return defaultStartPairResult("Classification request timed out; defaulting to Person↔Event.");
196
- }
197
- if (endpoint === "/api/ai/classify") return { type: "Event", description: "", isAtomic: false };
198
- }
199
- if (
200
- endpoint === "/api/ai/classify-start" &&
201
- !e.message?.includes("AI Proxy Error")
202
- ) {
137
+ if (endpoint === "/api/ai/classify-start" && !e.message?.includes("AI Proxy Error")) {
203
138
  // Network error or fetch failure, try fallback anyway if it's the start pair
204
139
  // console.warn(`⚠️ [Proxy] ${endpoint} failed, trying fallback /api/ai/classify`, e);
205
- return callAiProxy("/api/ai/classify", payload);
140
+ return callAiProxy("/api/ai/classify", body);
206
141
  }
207
142
  throw e;
208
143
  }
@@ -212,14 +147,14 @@ async function callAiProxy(endpoint: string, body: any) {
212
147
  * Helper to determine if we should use the proxy (browser + proxy URL available).
213
148
  */
214
149
  function shouldProxy(): boolean {
215
- if (typeof window === "undefined") return false;
150
+ if (typeof window === 'undefined') return false;
216
151
  if ((window as any).__PRERENDER_INJECTED) return false;
217
152
 
218
153
  const baseUrl = getEnvCacheUrl();
219
154
  return !!baseUrl;
220
155
  }
221
156
 
222
- export function defaultStartPairResult(reason: string): {
157
+ export function defaultStartPairResult(reason: string, term?: string): {
223
158
  type: string;
224
159
  description: string;
225
160
  isAtomic: boolean;
@@ -227,19 +162,105 @@ export function defaultStartPairResult(reason: string): {
227
162
  compositeType: string;
228
163
  reasoning: string;
229
164
  } {
165
+ const isPerson = term ? looksLikePersonName(term) : false;
230
166
  return {
231
- type: "Event",
167
+ type: isPerson ? "Person" : "Event",
232
168
  description: "",
233
- isAtomic: false,
169
+ isAtomic: isPerson,
234
170
  atomicType: "Person",
235
171
  compositeType: "Event",
236
172
  reasoning: reason,
237
173
  };
238
174
  }
239
175
 
176
+ /** Messy pasted now-playing / YouTube text; short single-line queries skip the extra Gemini call. */
177
+ function rawTermNeedsMusicEntityExtract(raw: string): boolean {
178
+ const t = raw.trim();
179
+ if (!t) return false;
180
+ if (t.length > 220) return true;
181
+ if (t.includes("\n")) return true;
182
+ if (/https?:\/\/(www\.)?(youtube\.com|youtu\.be)\b/i.test(t)) return true;
183
+ if (/\b(feat\.|ft\.|official\s+video|official\s+audio)\b/i.test(t)) return true;
184
+ return false;
185
+ }
186
+
187
+ /**
188
+ * Given raw pasted text (YouTube title, channel name, multi-line description),
189
+ * ask the LLM to pick the best graph starting node — using world knowledge to
190
+ * disambiguate and choose the most meaningful hub entity. Falls back to raw input on failure.
191
+ */
192
+ export const extractMusicEntity = async (raw: string): Promise<string> => {
193
+ const trimmed = raw.trim();
194
+ if (!trimmed) return trimmed;
195
+
196
+ const apiKey = await getApiKey();
197
+ if (!apiKey) return trimmed;
198
+
199
+ const ai = new GoogleGenAI({ apiKey });
200
+ const prompt = `You are given raw text from a music context (YouTube title, track listing, now-playing display, or search query). It may include one or more track titles, an artist name, a composer, a YouTube channel username, a year, or other metadata.
201
+
202
+ Return the single best name to use as a music knowledge-graph starting node — one that will connect meaningfully to related works, composers, performers, and styles. Use your world knowledge of music to pick an unambiguous, well-known entity.
203
+
204
+ Guidelines:
205
+ - When an artist/composer is present alongside a single track title, always prefer "Artist: Track" (e.g. "Daft Punk: One More Time"). A bare track title is rarely enough — the artist makes it unambiguous.
206
+ - When multiple track titles by the same artist are listed (e.g. separated by "/" or newlines), return just the artist name — it makes a better hub node.
207
+ - For classical music, prefer composer over performer; include the work title (e.g. "Fauré: Sicilienne").
208
+ - Use world knowledge to disambiguate ambiguous titles: if the artist clarifies the meaning, include them. "Around the World" alone could be Jules Verne; "Daft Punk: Around the World" is unambiguous.
209
+ - Ignore YouTube channel usernames (random strings, channel handles), record labels, streaming platform names, and bare years.
210
+
211
+ Examples:
212
+ - "Gautier Capuçon plays Fauré: Sicilienne, Op. 78\\nWarner Classics" → "Fauré: Sicilienne, Op. 78"
213
+ - "Alban Berg- Lyric Suite Part 3\\nplayingmusiconmars1926" → "Alban Berg: Lyric Suite"
214
+ - "György Ligeti - Mathieu Romano\\nLux Aeterna1966" → "György Ligeti: Lux Aeterna"
215
+ - "Ravel : Pavane (Orchestre national de France / Dalia Stasevska)\\nFrance Musique concerts1899" → "Ravel: Pavane pour une infante défunte"
216
+ - "Vaughan Williams ~ The Lark Ascending" → "Vaughan Williams: The Lark Ascending"
217
+ - "Hildegard von Bingen, O rubor sanguinis (with score)\\nhuakinthoi" → "Hildegard von Bingen: O rubor sanguinis"
218
+ - "Around the World / Harder, Better, Faster, Stronger\\nDaft Punk" → "Daft Punk"
219
+ - "One More Time\\nDaft Punk" → "Daft Punk: One More Time"
220
+ - "The Ends\\nSeth David2024" → "Seth David: The Ends"
221
+ - "10% (feat. Kali Uchis)\\nKAYTRANADA, Kali Uchis2019" → "KAYTRANADA: 10% (feat. Kali Uchis)"
222
+ - "Glenn Gould" → "Glenn Gould"
223
+ - "Bach: Goldberg Variations" → "Bach: Goldberg Variations"
224
+
225
+ Raw text:
226
+ """
227
+ ${trimmed}
228
+ """
229
+
230
+ Return JSON: { "entity": "<extracted entity name>" }`;
231
+
232
+ try {
233
+ const response = await withTimeout(
234
+ ai.models.generateContent({
235
+ model: getGeminiModelClassify(),
236
+ contents: prompt,
237
+ config: {
238
+ responseMimeType: "application/json",
239
+ responseSchema: {
240
+ type: Type.OBJECT,
241
+ properties: { entity: { type: Type.STRING } },
242
+ required: ["entity"]
243
+ }
244
+ }
245
+ }),
246
+ 8000,
247
+ "extractMusicEntity timed out"
248
+ );
249
+ const parsed = parseJsonFromModelText(getResponseText(response)) as { entity?: string } | null;
250
+ const entity = parsed?.entity?.trim();
251
+ if (entity && entity.length > 1) {
252
+ if (entity !== trimmed) console.log(`[extractMusicEntity] "${trimmed}" → "${entity}"`);
253
+ return entity;
254
+ }
255
+ } catch (e) {
256
+ console.warn("[extractMusicEntity] failed, using raw input:", String(e).slice(0, 120));
257
+ }
258
+ return trimmed;
259
+ };
260
+
240
261
  export const classifyStartPair = async (
241
- term: string,
242
- wikiContext?: string,
262
+ rawTerm: string,
263
+ wikiContext?: string
243
264
  ): Promise<{
244
265
  type: string;
245
266
  description: string;
@@ -248,56 +269,77 @@ export const classifyStartPair = async (
248
269
  compositeType: string;
249
270
  reasoning: string;
250
271
  }> => {
251
- if (shouldProxy()) {
252
- return callAiProxy("/api/ai/classify-start", { term, wikiContext });
272
+ // With `VITE_CACHE_URL`, classification hits the cache server first. Skip client-side
273
+ // `extractMusicEntity` (music/YouTube-specific Gemini call) it adds a full round-trip and
274
+ // the wrong prompt for films / general graph seeds like "The Godfather".
275
+ const cacheUrl = getEnvCacheUrl();
276
+ const proxy =
277
+ typeof window !== "undefined" &&
278
+ !(window as any).__PRERENDER_INJECTED &&
279
+ !!cacheUrl;
280
+ if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
281
+ let cacheHost = "";
282
+ try {
283
+ if (cacheUrl) cacheHost = new URL(cacheUrl).host;
284
+ } catch {
285
+ cacheHost = "(invalid)";
286
+ }
287
+ console.log("[Constellations]", "classifyStartPair", {
288
+ term: rawTerm.trim().slice(0, 80),
289
+ hasWikiContext: !!wikiContext,
290
+ useProxy: proxy,
291
+ cacheHost: cacheHost || null,
292
+ });
293
+ }
294
+ if (proxy) {
295
+ const proxyResult = await callAiProxy("/api/ai/classify-start", { term: rawTerm.trim(), wikiContext });
296
+ // Sanity check: if proxy says non-atomic but the term strongly looks like a person name, correct it.
297
+ if (!proxyResult.isAtomic && looksLikePersonName(rawTerm)) {
298
+ console.warn("[classifyStartPair] proxy returned isAtomic=false for apparent person name; overriding", rawTerm);
299
+ return { ...proxyResult, isAtomic: true, type: "Person" };
300
+ }
301
+ return proxyResult;
253
302
  }
254
303
 
255
- const apiKey = await getLlmApiKey();
304
+ const needsMusic = rawTermNeedsMusicEntityExtract(rawTerm);
305
+ if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
306
+ console.log("[Constellations]", "classifyStartPair local Gemini path", { needsMusicExtract: needsMusic });
307
+ }
308
+ const term = needsMusic ? await extractMusicEntity(rawTerm) : rawTerm.trim();
309
+
310
+ const apiKey = await getApiKey();
256
311
  // String-level safety heuristic (no Wikipedia required):
257
312
  // Disambiguated titles like "Discover (Daft Punk album)" must never be treated as Person.
258
313
  // Treat common work/media parentheticals as Composite/Event in the temporary Person↔Event model.
259
314
  const t = term.trim();
260
315
  // Academic heuristics (no model required):
261
316
  // If the seed looks like a paper/DOI/arXiv query, default to Author↔Paper so the system can use an academic corpus.
262
- if (
263
- /\b10\.\d{4,9}\/\S+\b/i.test(t) ||
264
- /\barxiv\b|arxiv:\s*\d{4}\.\d{4,5}/i.test(t)
265
- ) {
317
+ if (/\b10\.\d{4,9}\/\S+\b/i.test(t) || /\barxiv\b|arxiv:\s*\d{4}\.\d{4,5}/i.test(t)) {
266
318
  return {
267
319
  type: "Paper",
268
320
  description: "",
269
321
  isAtomic: false,
270
322
  atomicType: "Author",
271
323
  compositeType: "Paper",
272
- reasoning:
273
- "Seed looks like an academic paper identifier (DOI/arXiv); selecting Author↔Paper.",
324
+ reasoning: "Seed looks like an academic paper identifier (DOI/arXiv); selecting Author↔Paper."
274
325
  };
275
326
  }
276
- if (
277
- /\((album|song|single|film|movie|tv series|television series|book|novel|painting|sculpture|artwork|opera|symphony)\)/i.test(
278
- t,
279
- )
280
- ) {
327
+ if (/\((album|song|single|film|movie|tv series|television series|book|novel|painting|sculpture|artwork|opera|symphony)\)/i.test(t)) {
281
328
  return {
282
329
  type: "Event",
283
330
  description: "",
284
331
  isAtomic: false,
285
332
  atomicType: "Person",
286
333
  compositeType: "Event",
287
- reasoning:
288
- "Title contains an explicit work/media disambiguator (e.g., '(album)'); treating it as Composite in Person↔Event.",
334
+ reasoning: "Title contains an explicit work/media disambiguator (e.g., '(album)'); treating it as Composite in Person↔Event."
289
335
  };
290
336
  }
291
337
 
338
+
292
339
  if (!apiKey) {
293
- return defaultStartPairResult(
294
- "No API key available; defaulting to Person↔Event.",
295
- );
340
+ return defaultStartPairResult("No API key available; defaulting to Person↔Event.", term);
296
341
  }
297
342
 
298
- const useGemini = getLlmProvider() === "gemini";
299
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
300
-
301
343
  const prompt = `Choose the most appropriate bipartite pair for this session based on the input: "${term}".
302
344
 
303
345
  You may identify other valid bipartite structures if appropriate for "${term}".
@@ -308,64 +350,61 @@ Rules:
308
350
  - If "${term}" is an organization/institution/band, it is ALWAYS COMPOSITE.
309
351
  - If "${term}" looks like an academic paper or DOI/arXiv, it is COMPOSITE (use Author ↔ Paper).
310
352
  - If "${term}" is a very famous person, it is ATOMIC even if they have works.
353
+ - CLASSICAL MUSIC PERFORMANCE: If "${term}" matches the pattern "Performer plays/performs Composer: Work" or "Composer - Work (Performer)" or "Performer - Composer: Work", classify it as COMPOSITE (type: Performance or Composition) with pair Performer ↔ Composition. When expanded, this node should yield BOTH the composer AND the performer as atomic connections.
311
354
  `;
312
355
 
313
356
  try {
314
- const rawText = await runJsonCompletion({
315
- user: prompt,
316
- timeoutMs: CLASSIFY_TIMEOUT_MS,
317
- attempts: 3,
318
- gemini: gemini
319
- ? () =>
320
- gemini.models.generateContent({
321
- model: getGeminiModelClassify(),
322
- contents: prompt,
323
- config: {
324
- responseMimeType: "application/json",
325
- responseSchema: {
326
- type: Type.OBJECT,
327
- properties: {
328
- type: { type: Type.STRING },
329
- description: { type: Type.STRING },
330
- isAtomic: { type: Type.BOOLEAN },
331
- atomicType: { type: Type.STRING },
332
- compositeType: { type: Type.STRING },
333
- reasoning: { type: Type.STRING },
334
- },
335
- required: ["type", "isAtomic", "atomicType", "compositeType"],
336
- },
337
- },
338
- })
339
- : undefined,
357
+ const ai = new GoogleGenAI({ apiKey });
358
+
359
+ const makeApiCall = () => ai.models.generateContent({
360
+ model: getGeminiModelClassify(),
361
+ contents: prompt,
362
+ config: {
363
+ responseMimeType: "application/json",
364
+ responseSchema: {
365
+ type: Type.OBJECT,
366
+ properties: {
367
+ type: { type: Type.STRING },
368
+ description: { type: Type.STRING },
369
+ isAtomic: { type: Type.BOOLEAN },
370
+ atomicType: { type: Type.STRING },
371
+ compositeType: { type: Type.STRING },
372
+ reasoning: { type: Type.STRING }
373
+ },
374
+ required: ["type", "isAtomic", "atomicType", "compositeType"]
375
+ }
376
+ }
340
377
  });
341
- // console.log(`🤖 [Gemini] Raw Classify-Start response for "${term}":`, rawText);
342
- const text = cleanJson(rawText);
343
- const json = text ? JSON.parse(text) : {};
344
378
 
379
+ const response = await withRetry(
380
+ () => withTimeout(makeApiCall(), CLASSIFY_TIMEOUT_MS, "Start-pair classification timed out"),
381
+ 3,
382
+ 1000
383
+ );
384
+
385
+ const rawText = getResponseText(response);
386
+ const parsed = parseJsonFromModelText(rawText);
387
+ const json = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
388
+
389
+ const s = (v: unknown, fallback: string) => (typeof v === "string" && v ? v : fallback);
345
390
  return {
346
- type: json.type || "Event",
347
- description: json.description || "",
391
+ type: s(json.type, "Event"),
392
+ description: s(json.description, ""),
348
393
  isAtomic: !!json.isAtomic,
349
- atomicType: json.atomicType || "Person",
350
- compositeType: json.compositeType || "Event",
351
- reasoning: json.reasoning || "",
394
+ atomicType: s(json.atomicType, "Person"),
395
+ compositeType: s(json.compositeType, "Event"),
396
+ reasoning: s(json.reasoning, "")
352
397
  };
353
398
  } catch (e: any) {
354
- const msg = String(e?.message || e || "");
355
- console.warn(
356
- `[classifyStartPair] failed for "${term}":`,
357
- msg.slice(0, 200),
358
- );
399
+ console.warn("[classifyStartPair]", term, String(e?.message || e).slice(0, 200));
359
400
  return defaultStartPairResult(
360
- "Classification API unavailable (quota, rate limit, or error); defaulting to Person↔Event.",
401
+ "Classification API unavailable (quota/rate limit or error); defaulting to Person↔Event.",
402
+ term
361
403
  );
362
404
  }
363
405
  };
364
406
 
365
- export const classifyEntity = async (
366
- term: string,
367
- wikiContext?: string,
368
- ): Promise<{
407
+ export const classifyEntity = async (term: string, wikiContext?: string): Promise<{
369
408
  type: string;
370
409
  description: string;
371
410
  isAtomic: boolean;
@@ -377,33 +416,29 @@ export const classifyEntity = async (
377
416
  return callAiProxy("/api/ai/classify", { term, wikiContext });
378
417
  }
379
418
 
380
- const apiKey = await getLlmApiKey();
419
+ const apiKey = await getApiKey();
381
420
  const normalized = term.trim().toLowerCase();
382
421
 
383
422
  // String-level safety heuristic (no Wikipedia required):
384
423
  // Disambiguated titles like "... (album)" must never be treated as Person.
385
- if (
386
- /\((album|song|single|film|movie|tv series|television series|book|novel|painting|sculpture|artwork|opera|symphony)\)/i.test(
387
- term.trim(),
388
- )
389
- ) {
424
+ if (/\((album|song|single|film|movie|tv series|television series|book|novel|painting|sculpture|artwork|opera|symphony)\)/i.test(term.trim())) {
390
425
  return {
391
426
  type: "Event",
392
427
  description: "",
393
428
  isAtomic: false,
394
429
  atomicType: "Person",
395
430
  compositeType: "Event",
396
- reasoning:
397
- "Title contains an explicit work/media disambiguator (e.g., '(album)'); treating it as Composite in Person↔Event.",
431
+ reasoning: "Title contains an explicit work/media disambiguator (e.g., '(album)'); treating it as Composite in Person↔Event."
398
432
  };
399
433
  }
400
434
 
435
+
401
436
  if (!apiKey) {
402
437
  console.error("❌ [Gemini] classifyEntity: No API key found");
403
- return { type: "Event", description: "", isAtomic: false };
438
+ return { type: 'Event', description: '', isAtomic: false };
404
439
  }
405
- const useGemini = getLlmProvider() === "gemini";
406
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
440
+ // console.log(`🧪 [Gemini] classify start`, { term, timeoutMs: CLASSIFY_TIMEOUT_MS });
441
+ const ai = new GoogleGenAI({ apiKey });
407
442
 
408
443
  const wikiPrompt = wikiContext
409
444
  ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
@@ -437,52 +472,51 @@ export const classifyEntity = async (
437
472
 
438
473
  // console.log("🤖 [Gemini] Classify Prompt:", prompt);
439
474
 
440
- const rawText = await runJsonCompletion({
441
- user: prompt,
442
- timeoutMs: CLASSIFY_TIMEOUT_MS,
443
- attempts: 3,
444
- gemini: gemini
445
- ? () =>
446
- gemini.models.generateContent({
447
- model: getGeminiModelClassify(),
448
- contents: prompt,
449
- config: {
450
- responseMimeType: "application/json",
451
- responseSchema: {
452
- type: Type.OBJECT,
453
- properties: {
454
- type: { type: Type.STRING },
455
- description: {
456
- type: Type.STRING,
457
- description: "Short 1-sentence description",
458
- },
459
- isAtomic: { type: Type.BOOLEAN },
460
- atomicType: { type: Type.STRING },
461
- compositeType: { type: Type.STRING },
462
- reasoning: { type: Type.STRING },
463
- },
464
- required: ["type", "isAtomic", "atomicType", "compositeType"],
465
- },
466
- },
467
- })
468
- : undefined,
475
+ const makeApiCall = () => ai.models.generateContent({
476
+ model: getGeminiModelClassify(),
477
+ contents: prompt,
478
+ config: {
479
+ responseMimeType: "application/json",
480
+ responseSchema: {
481
+ type: Type.OBJECT,
482
+ properties: {
483
+ type: { type: Type.STRING },
484
+ description: { type: Type.STRING, description: "Short 1-sentence description" },
485
+ isAtomic: { type: Type.BOOLEAN },
486
+ atomicType: { type: Type.STRING },
487
+ compositeType: { type: Type.STRING },
488
+ reasoning: { type: Type.STRING }
489
+ },
490
+ required: ["type", "isAtomic", "atomicType", "compositeType"]
491
+ }
492
+ }
469
493
  });
494
+
495
+ const response = await withRetry(
496
+ () => withTimeout(makeApiCall(), CLASSIFY_TIMEOUT_MS, "Classification timed out"),
497
+ 3,
498
+ 1000
499
+ );
500
+
501
+ const rawText = getResponseText(response);
470
502
  // console.log(`🤖 [Gemini] Raw Classify response for "${term}":`, rawText);
471
- const text = cleanJson(rawText);
503
+ const json = parseJsonFromModelText(rawText);
472
504
  // console.log("Classify response text:", text);
473
- if (!text) return { type: "Event", description: "", isAtomic: false };
474
- const json = JSON.parse(text);
505
+ if (!json || typeof json !== "object" || Array.isArray(json)) {
506
+ return { type: 'Event', description: '', isAtomic: false };
507
+ }
508
+ const o = json as Record<string, unknown>;
475
509
  return {
476
- type: json.type || "Event",
477
- description: json.description || "",
478
- isAtomic: !!json.isAtomic,
479
- atomicType: json.atomicType,
480
- compositeType: json.compositeType,
481
- reasoning: json.reasoning,
510
+ type: (o.type as string) || 'Event',
511
+ description: (o.description as string) || '',
512
+ isAtomic: !!o.isAtomic,
513
+ atomicType: o.atomicType as string | undefined,
514
+ compositeType: o.compositeType as string | undefined,
515
+ reasoning: o.reasoning as string | undefined
482
516
  };
483
517
  } catch (error) {
484
518
  // console.warn("Classification failed, defaulting to Event:", error);
485
- return { type: "Event", description: "", isAtomic: false };
519
+ return { type: 'Event', description: '', isAtomic: false };
486
520
  }
487
521
  };
488
522
 
@@ -494,95 +528,84 @@ export const fetchConnections = async (
494
528
  wikipediaId?: string,
495
529
  atomicType?: string,
496
530
  compositeType?: string,
497
- mentioningPageTitles?: string[],
531
+ mentioningPageTitles?: string[]
498
532
  ): Promise<GeminiResponse> => {
499
533
  if (shouldProxy()) {
500
- return callAiProxy("/api/ai/connections", {
501
- nodeName,
502
- context,
503
- excludeNodes,
504
- wikiContext,
505
- wikipediaId,
506
- atomicType,
507
- compositeType,
508
- mentioningPageTitles,
509
- });
534
+ return callAiProxy("/api/ai/connections", { nodeName, context, excludeNodes, wikiContext, wikipediaId, atomicType, compositeType, mentioningPageTitles });
510
535
  }
511
536
 
512
- try {
513
- const apiKey = await getLlmApiKey();
514
- if (!apiKey) {
515
- console.error("[Gemini] fetchConnections: No API key found");
516
- return { people: [] };
537
+ const apiKey = await getApiKey();
538
+ if (!apiKey) {
539
+ if (process.env.NODE_ENV !== "production") {
540
+ console.warn("[Gemini] fetchConnections: No API key — returning empty graph expansion");
517
541
  }
542
+ return { people: [] };
543
+ }
518
544
 
519
- const useGemini = getLlmProvider() === "gemini";
520
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
545
+ const ai = new GoogleGenAI({ apiKey });
521
546
 
522
- const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
523
- const contextualPrompt = context
524
- ? `Analyze: "${nodeName}"${wikiIdStr} specifically in the context of "${context}".`
525
- : `Analyze: "${nodeName}"${wikiIdStr}.`;
547
+ const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
548
+ const contextualPrompt = context
549
+ ? `Analyze: "${nodeName}"${wikiIdStr} specifically in the context of "${context}".`
550
+ : `Analyze: "${nodeName}"${wikiIdStr}.`;
526
551
 
527
- const wikiPrompt = wikiContext
528
- ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
529
- : "";
552
+ const wikiPrompt = wikiContext
553
+ ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
554
+ : "";
555
+
556
+ const excludePrompt = excludeNodes.length > 0
557
+ ? `\nDO NOT include the following already known connections: ${JSON.stringify(excludeNodes)}. Find NEW high-impact connections.`
558
+ : "";
559
+
560
+ const mentionPrompt = mentioningPageTitles && mentioningPageTitles.length > 0
561
+ ? `\nIMPORTANT: This entity does not have a dedicated Wikipedia article, but it is explicitly mentioned in the following Wikipedia articles: ${mentioningPageTitles.join(', ')}. You MUST investigate these contexts and include relevant connections found there.`
562
+ : "";
530
563
 
531
- const excludePrompt =
532
- excludeNodes.length > 0
533
- ? `\nDO NOT include the following already known connections: ${JSON.stringify(excludeNodes)}. Find NEW high-impact connections.`
534
- : "";
535
-
536
- const mentionPrompt =
537
- mentioningPageTitles && mentioningPageTitles.length > 0
538
- ? `\nIMPORTANT: This entity does not have a dedicated Wikipedia article, but it is explicitly mentioned in the following Wikipedia articles: ${mentioningPageTitles.join(", ")}. You MUST investigate these contexts and include relevant connections found there.`
539
- : "";
540
-
541
- const atomicLabel = atomicType || "ATOMIC entity";
542
- const compositeLabel = compositeType || "COMPOSITE entity";
543
- const personOnlyRule =
544
- (atomicType || "").trim().toLowerCase() === "person"
545
- ? `\nCRITICAL: The atomic side is "Person" meaning INDIVIDUAL HUMAN BEINGS ONLY.
564
+ const atomicLabel = atomicType || "ATOMIC entity";
565
+ const compositeLabel = compositeType || "COMPOSITE entity";
566
+ const personOnlyRule =
567
+ (atomicType || "").trim().toLowerCase() === "person"
568
+ ? `\nCRITICAL: The atomic side is "Person" meaning INDIVIDUAL HUMAN BEINGS ONLY.
546
569
  - Return ONLY specific individual people with proper names (e.g., "Leonardo da Vinci"), not categories, groups, or locations.
547
570
  - DO NOT return organizations, institutions, committees, councils, companies, museums, foundations, agencies, or any group entities (e.g., do NOT return "Republic of Florence" as a person).
548
571
  - DO NOT return locations, places, buildings, or geographical entities (e.g., do NOT return "Florence" or "Italy").
549
572
  - DO NOT return generic or collective phrases like "Various Local Artists", "Local Artists", "Staff", "Visitors", "Students", "Members", "Volunteers", "Team", "The Public", "Curators".
550
573
  - If you cannot find enough specific individual humans, return fewer.`
551
- : "";
552
- const workSourceHint =
553
- (compositeType || "").trim().toLowerCase() === "event"
554
- ? `\nIf the Source is a named work (e.g., artwork/painting/sculpture/album/book/novel/film), you MUST return the primary creator(s) (author, artist, director, etc.) as the first few results. DO NOT omit the creator even if they are already widely known. Return people directly connected to the work (creator, depicted subject/model if distinct, commissioners/patrons, notable collectors/owners, curators/restorers/biographers explicitly associated).
574
+ : "";
575
+ const workSourceHint =
576
+ (compositeType || "").trim().toLowerCase() === "event"
577
+ ? `\nIf the Source is a named work (e.g., artwork/painting/sculpture/album/book/novel/film), you MUST return the primary creator(s) (author, artist, director, etc.) as the first few results. DO NOT omit the creator even if they are already widely known. Return people directly connected to the work (creator, depicted subject/model if distinct, commissioners/patrons, notable collectors/owners, curators/restorers/biographers explicitly associated).
555
578
  - Do NOT invent names; if only the creator is reliably connected, return only that person.`
556
- : "";
557
- const theorySourceHint =
558
- /\b(theory|concept|discovery|law|principle|formula|field|science|physics|mathematics|biology|chemistry|mechanics|evolution|relativity)\b/i.test(
559
- compositeType || "",
560
- ) ||
561
- /\b(theory|physics|mathematics|discovery|principle|mechanics|evolution|relativity)\b/i.test(
562
- nodeName,
563
- )
564
- ? `\nSPECIAL CASE (theory/concept/discovery): If the Source is a scientific theory, concept, or discovery, return the primary scientists, authors, or discoverers who established or significantly developed it.`
565
- : "";
579
+ : "";
580
+ const theorySourceHint =
581
+ /\b(theory|concept|discovery|law|principle|formula|field|science|physics|mathematics|biology|chemistry|mechanics|evolution|relativity)\b/i.test(compositeType || "") ||
582
+ /\b(theory|physics|mathematics|discovery|principle|mechanics|evolution|relativity)\b/i.test(nodeName)
583
+ ? `\nSPECIAL CASE (theory/concept/discovery): If the Source is a scientific theory, concept, or discovery, return the primary scientists, authors, or discoverers who established or significantly developed it.`
584
+ : "";
585
+
566
586
 
587
+ try {
567
588
  const prompt = `${contextualPrompt}${wikiPrompt}${mentionPrompt}${excludePrompt}
568
589
  Source Node: ${nodeName} (Type: ${compositeLabel})
569
590
 
570
- Return ${excludeNodes.length > 0 ? "12-15 NEW" : "10-12 key"} ${atomicLabel} entities (participants, creators, major figures, stars, ingredients, its most famous writers/editors for magazines, etc.) that are fundamental components of this ${compositeLabel}.
591
+ Return ${excludeNodes.length > 0 ? '6-8 NEW' : '5-6 key'} ${atomicLabel} entities (participants, creators, major figures, stars, ingredients, its most famous writers/editors for magazines, etc.) that are fundamental components of this ${compositeLabel}.
571
592
 
572
593
  Straying Guardrails:
573
594
  ${personOnlyRule}
574
595
  ${workSourceHint}
575
596
  ${theorySourceHint}
576
- ${(compositeType || "").match(/^(Movie|Film|Book|Novel|Play|Opera)$/i) ? "\nSPECIAL CASE (Fiction): For works of fiction, prioritize returning CHARACTERS as the atomic entities." : ""}
577
- ${(compositeType || "").match(/^(Magazine|Newspaper|Journal|Periodical|Publication)$/i) ? "\nSPECIAL CASE (Magazine): For periodicals/magazines, prioritize returning its most FAMOUS AND LONG-TIME WRITERS, columnists, and editors-in-chief. If some of these are already in the graph, find other significant figures." : ""}
597
+ ${(compositeType || "").match(/^(Movie|Film|Book|Novel|Play|Opera)$/i) ? '\nSPECIAL CASE (Fiction): For works of fiction, prioritize returning CHARACTERS as the atomic entities.' : ''}
598
+ ${(compositeType || "").match(/^(Magazine|Newspaper|Journal|Periodical|Publication)$/i) ? '\nSPECIAL CASE (Magazine): For periodicals/magazines, prioritize returning its most FAMOUS AND LONG-TIME WRITERS, columnists, and editors-in-chief. If some of these are already in the graph, find other significant figures.' : ''}
578
599
 
600
+ SPECIAL CASE (classical music recording): If the Source Node title contains a composer's name — patterns like "Bach: Goldberg Variations", "Ligeti — Lux Aeterna", "Composer - Performer / Work Title" — you MUST return the COMPOSER as the first atomic entity. Do NOT return the performer/interpreter as the primary result. The composer whose name appears in the title is the most important connection.
601
+
579
602
  CRITICAL BIPARTITE RULE:
580
603
  - The Source Node is a COMPOSITE entity.
581
604
  - Therefore, ALL returned entities MUST be ATOMIC entities (${atomicLabel}).
582
605
  - DO NOT return other ${compositeLabel} entities.
583
606
  - If you find connections to other ${compositeLabel} entities, you MUST find the ${atomicLabel} entities (people, characters, etc.) that link them.
584
607
 
585
- ${excludeNodes.length > 0 ? `\nEXPAND MORE: Since you have already provided some connections, please dig deeper into the "next tier" of significant entities. Avoid the obvious names already in the graph: ${JSON.stringify(excludeNodes)}.` : ""}
608
+ ${excludeNodes.length > 0 ? `\nEXPAND MORE: Since you have already provided some connections, please dig deeper into the "next tier" of significant entities. Avoid the obvious names already in the graph: ${JSON.stringify(excludeNodes)}.` : ''}
586
609
 
587
610
  IMPORTANT: For each entity specify its type (${atomicLabel}) and whether it follows the classification rules defined in the system instruction.
588
611
 
@@ -597,89 +620,56 @@ export const fetchConnections = async (
597
620
 
598
621
  // console.log(`🤖 [Gemini] fetchConnections Prompt for "${nodeName}":`, prompt);
599
622
 
600
- const rawText = await runJsonCompletion({
601
- system: SYSTEM_INSTRUCTION,
602
- user: prompt,
603
- timeoutMs: GEMINI_TIMEOUT_MS,
604
- attempts: 4,
605
- gemini: gemini
606
- ? () =>
607
- gemini.models.generateContent({
608
- model: getGeminiModel(),
609
- contents: prompt,
610
- config: {
611
- systemInstruction: SYSTEM_INSTRUCTION,
612
- responseMimeType: "application/json",
613
- responseSchema: {
614
- type: Type.OBJECT,
615
- properties: {
616
- sourceYear: {
617
- type: Type.INTEGER,
618
- description: "Year of the source node",
619
- },
620
- people: {
621
- type: Type.ARRAY,
622
- items: {
623
- type: Type.OBJECT,
624
- properties: {
625
- name: { type: Type.STRING },
626
- isAtomic: {
627
- type: Type.BOOLEAN,
628
- nullable: true,
629
- description: "True if atomic, false if composite",
630
- },
631
- wikipediaTitle: {
632
- type: Type.STRING,
633
- nullable: true,
634
- description:
635
- "Canonical English Wikipedia article title for this entity (use disambiguation parentheses when needed)",
636
- },
637
- role: {
638
- type: Type.STRING,
639
- nullable: true,
640
- description: "Role in the requested Source Node",
641
- },
642
- description: {
643
- type: Type.STRING,
644
- nullable: true,
645
- description: "Short 1-sentence bio",
646
- },
647
- evidenceSnippet: {
648
- type: Type.STRING,
649
- description:
650
- "1 sentence evidence; if VERIFIED INFORMATION is provided, prefer verbatim from it",
651
- },
652
- evidencePageTitle: {
653
- type: Type.STRING,
654
- description:
655
- "Wikipedia page title where the snippet came from (usually the source)",
656
- },
657
- },
658
- required: [
659
- "name",
660
- "evidenceSnippet",
661
- "evidencePageTitle",
662
- ],
663
- },
664
- },
665
- },
666
- required: ["people"],
623
+ const makeApiCall = () => ai.models.generateContent({
624
+ model: getGeminiModel(),
625
+ contents: prompt,
626
+ config: {
627
+ systemInstruction: SYSTEM_INSTRUCTION,
628
+ responseMimeType: "application/json",
629
+ responseSchema: {
630
+ type: Type.OBJECT,
631
+ properties: {
632
+ sourceYear: { type: Type.INTEGER, description: "Year of the source node" },
633
+ people: {
634
+ type: Type.ARRAY,
635
+ items: {
636
+ type: Type.OBJECT,
637
+ properties: {
638
+ name: { type: Type.STRING },
639
+ isAtomic: { type: Type.BOOLEAN, nullable: true, description: "True if atomic, false if composite" },
640
+ wikipediaTitle: { type: Type.STRING, nullable: true, description: "Canonical English Wikipedia article title for this entity (use disambiguation parentheses when needed)" },
641
+ role: { type: Type.STRING, nullable: true, description: "Role in the requested Source Node" },
642
+ description: { type: Type.STRING, nullable: true, description: "Short 1-sentence bio" },
643
+ evidenceSnippet: { type: Type.STRING, description: "1 sentence evidence; if VERIFIED INFORMATION is provided, prefer verbatim from it" },
644
+ evidencePageTitle: { type: Type.STRING, description: "Wikipedia page title where the snippet came from (usually the source)" }
667
645
  },
668
- },
669
- })
670
- : undefined,
646
+ required: ["name", "evidenceSnippet", "evidencePageTitle"]
647
+ }
648
+ }
649
+ },
650
+ required: ["people"]
651
+ }
652
+ }
671
653
  });
654
+
655
+ const response = await withRetry(
656
+ () => withTimeout(makeApiCall(), GEMINI_TIMEOUT_MS, "Gemini API request timed out"),
657
+ 4,
658
+ 1000
659
+ );
660
+
661
+ const rawText = getResponseText(response);
672
662
  // console.log(`🤖 [Gemini] Raw response for "${nodeName}":`, rawText);
673
- const text = cleanJson(rawText);
674
- if (!text) return { people: [] };
663
+ const parsed = parseJsonFromModelText(rawText) as GeminiResponse | null;
664
+ if (!parsed || !Array.isArray(parsed.people)) return { people: [] };
675
665
 
676
- const parsed = JSON.parse(text) as GeminiResponse;
677
- const list = Array.isArray(parsed.people) ? parsed.people : [];
678
- // Force correct bipartite type regardless of LLM slip-ups
679
- parsed.people = list.map((p) => ({
680
- ...p,
681
- isAtomic: true, // In fetchConnections, the source is COMPOSITE, so all results MUST be ATOMIC (true)
682
- }));
666
+ // Force correct bipartite type regardless of LLM slip-ups; drop junk entity names
667
+ parsed.people = parsed.people
668
+ .filter(p => isValidEntityName(p.name))
669
+ .map(p => ({
670
+ ...p,
671
+ isAtomic: true // In fetchConnections, the source is COMPOSITE, so all results MUST be ATOMIC (true)
672
+ }));
683
673
 
684
674
  return parsed;
685
675
  } catch (error) {
@@ -695,99 +685,47 @@ export const fetchPersonWorks = async (
695
685
  wikipediaId?: string,
696
686
  atomicType?: string,
697
687
  compositeType?: string,
698
- mentioningPageTitles?: string[],
688
+ mentioningPageTitles?: string[]
699
689
  ): Promise<PersonWorksResponse> => {
700
690
  if (shouldProxy()) {
701
- const resp: any = await callAiProxy("/api/ai/works", {
702
- nodeName,
703
- excludeNodes,
704
- wikiContext,
705
- wikipediaId,
706
- atomicType,
707
- compositeType,
708
- mentioningPageTitles,
709
- });
710
-
711
- // Compatibility: older/newer proxy servers (and some providers) return `{ entities: [...] }` instead of `{ works: [...] }`.
712
- // Normalize here so the UI can use the data even if the cache server hasn't been updated.
713
- if (resp && (!Array.isArray(resp.works) || resp.works.length === 0) && Array.isArray(resp.entities)) {
714
- const entities = resp.entities as any[];
715
- const works = entities
716
- .map((e) => {
717
- const wikiTitle =
718
- typeof e?.wikipediaTitle === "string" ? e.wikipediaTitle.trim() : "";
719
- const entity =
720
- typeof e?.entity === "string" && e.entity.trim()
721
- ? e.entity.trim()
722
- : wikiTitle;
723
- if (!entity) return null;
724
- return {
725
- entity,
726
- wikipediaTitle: wikiTitle || undefined,
727
- type:
728
- typeof e?.type === "string" && e.type.trim()
729
- ? e.type.trim()
730
- : compositeType || "Event",
731
- description: typeof e?.description === "string" ? e.description : "",
732
- role: typeof e?.role === "string" ? e.role : "",
733
- year:
734
- e?.year === null || e?.year === undefined || isNaN(Number(e.year))
735
- ? (undefined as any)
736
- : Number(e.year),
737
- isAtomic: false,
738
- evidenceSnippet: typeof e?.evidenceSnippet === "string" ? e.evidenceSnippet : "",
739
- evidencePageTitle:
740
- typeof e?.evidencePageTitle === "string" ? e.evidencePageTitle : nodeName,
741
- } as PersonWork;
742
- })
743
- .filter(Boolean) as PersonWork[];
744
- return { ...resp, works };
745
- }
746
-
747
- return resp as PersonWorksResponse;
691
+ return callAiProxy("/api/ai/works", { nodeName, excludeNodes, wikiContext, wikipediaId, atomicType, compositeType, mentioningPageTitles });
748
692
  }
749
693
 
750
- try {
751
- const apiKey = await getLlmApiKey();
752
- if (!apiKey) {
753
- console.error("[Gemini] fetchPersonWorks: No API key found");
754
- return { works: [] };
694
+ const apiKey = await getApiKey();
695
+ if (!apiKey) {
696
+ if (process.env.NODE_ENV !== "production") {
697
+ console.warn("[Gemini] fetchPersonWorks: No API key — returning empty works");
755
698
  }
699
+ return { works: [] };
700
+ }
756
701
 
757
- const useGemini = getLlmProvider() === "gemini";
758
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
702
+ const ai = new GoogleGenAI({ apiKey });
759
703
 
760
- const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
761
- const wikiPrompt = wikiContext
762
- ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
763
- : "";
704
+ const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
705
+ const wikiPrompt = wikiContext
706
+ ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
707
+ : "";
764
708
 
765
- const atomicLabel = atomicType || "ATOMIC entity";
766
- const compositeLabel = compositeType || "COMPOSITE entity";
709
+ const atomicLabel = atomicType || "ATOMIC entity";
710
+ const compositeLabel = compositeType || "COMPOSITE entity";
767
711
 
768
- const mentionPrompt =
769
- mentioningPageTitles && mentioningPageTitles.length > 0
770
- ? `\nIMPORTANT: This person does not have a dedicated Wikipedia article, but they are explicitly mentioned in these Wikipedia articles: ${mentioningPageTitles.join(", ")}. Prioritize these as the primary ${compositeLabel} connections for this person.`
771
- : "";
712
+ const mentionPrompt = mentioningPageTitles && mentioningPageTitles.length > 0
713
+ ? `\nIMPORTANT: This person does not have a dedicated Wikipedia article, but they are explicitly mentioned in these Wikipedia articles: ${mentioningPageTitles.join(', ')}. Prioritize these as the primary ${compositeLabel} connections for this person.`
714
+ : "";
772
715
 
773
- const dateRequired =
774
- (compositeType || "").match(
775
- /^(Event|Paper|Work|Movie|Film|Book|Novel|Album|Song|Composition|Artwork|Painting|Sculpture)$/i,
776
- ) ||
777
- compositeLabel.toLowerCase().includes("event") ||
778
- compositeLabel.toLowerCase().includes("work");
716
+ const dateRequired = (compositeType || "").match(/^(Event|Paper|Work|Movie|Film|Book|Novel|Album|Song|Composition|Artwork|Painting|Sculpture)$/i) ||
717
+ (compositeLabel.toLowerCase().includes('event') || compositeLabel.toLowerCase().includes('work'));
779
718
 
780
- const dateRequirementPrompt = dateRequired
781
- ? `\nDATE REQUIREMENT:
719
+ const dateRequirementPrompt = dateRequired
720
+ ? `\nDATE REQUIREMENT:
782
721
  - Every ${compositeLabel} MUST have a valid year (creation, publication, start date, or occurrence).
783
722
  - If you do not know the year, DO NOT include the entity.`
784
- : "";
723
+ : "";
785
724
 
786
- const contextPrompt =
787
- excludeNodes.length > 0
788
- ? `The user graph already contains these nodes connected to ${nodeName}${wikiIdStr}: ${JSON.stringify(excludeNodes)}.
789
- Return 12-15 NEW significant ${compositeLabel} entities.`
790
- : `List 10-12 DISTINCT, significant ${compositeLabel} entities that this ${atomicLabel} "${nodeName}"${wikiIdStr} belongs to or is part of.
725
+ const contextPrompt = excludeNodes.length > 0
726
+ ? `The user graph already contains these nodes connected to ${nodeName}${wikiIdStr}: ${JSON.stringify(excludeNodes)}.
727
+ Return 6-8 NEW significant ${compositeLabel} entities.`
728
+ : `List 5-6 DISTINCT, significant ${compositeLabel} entities that this ${atomicLabel} "${nodeName}"${wikiIdStr} belongs to or is part of.
791
729
 
792
730
  CRITICAL: A ${compositeLabel} must be a named organization, team, project, work, recipe, disease, location, or specific historical event/incident.
793
731
  DO NOT return descriptive phrases, facts, or achievements.
@@ -828,6 +766,11 @@ export const fetchPersonWorks = async (
828
766
  - Set the returned item's "type" to "Album" (or "Composition" / "Symphony" / "Song" when clearly applicable).
829
767
  - QUOTA: For a musician, return AT LEAST 6-8 specific major albums or compositions.
830
768
 
769
+ SPECIAL CASE (classical performer/conductor): If "${nodeName}" is primarily a performer or conductor (not a composer), name every returned composition as "Composer: Work Title" — e.g., "Ravel: Pavane pour une infante défunte", "Beethoven: Symphony No. 9".
770
+ - DO NOT include the performer's name, orchestra name, label, or recording info in the composition title.
771
+ - DO NOT return YouTube channel names, concert series names, or recording platforms (e.g., "France Musique concerts1899", "DG Archive") as entities.
772
+ - This naming convention ensures the composer's name travels with the node and can be extracted when the node is later expanded.
773
+
831
774
  SPECIAL CASE (ingredient/food): If "${nodeName}" is an ingredient or food item, return 8-10 specific recipes that prominently feature this ingredient.
832
775
  - Set the returned item's "type" field to "Recipe".
833
776
  - Return well-known, named recipes (e.g., for "Beef": "Beef Wellington", "Beef Bourguignon", "Steak Tartare", "Korean Bulgogi", "Beef Stroganoff", "Pho", "Beef Rendang", "Chili con Carne").
@@ -852,7 +795,6 @@ export const fetchPersonWorks = async (
852
795
  - For a Person involved in a recent event: Return the named Event or Incident (e.g. "Killing of Renee Good", "2026 Minneapolis Protests").
853
796
  - For an Ingredient (e.g. "Chicken"): Return specific Recipes.
854
797
  - For an Actor: Return specific Movies.
855
- - For a film director, producer, or screenwriter: Return their best-known directed (or written) films and major series; each entry MUST include a 4-digit release or first-air year in the "year" field whenever known.
856
798
  - For an Artist: Return specific major Artworks (e.g., "Mona Lisa", "The Last Supper") and optionally a few key Exhibitions/Movements.
857
799
  - For a Mathematician: Return specific named Papers (often coauthored).
858
800
 
@@ -862,157 +804,66 @@ export const fetchPersonWorks = async (
862
804
  - DO NOT return other ${atomicLabel} entities (other people, actors, or characters).
863
805
  - If "Bugs Bunny" has a rivalry with "Daffy Duck", DO NOT return "Daffy Duck". Instead, return the specific MOVIES or SERIES they appear in together.`;
864
806
 
807
+ try {
865
808
  const prompt = `${wikiPrompt}${mentionPrompt}${contextPrompt}
866
- Ensure each entry is a different entity. ${dateRequired ? "Sort by year. STRICTLY avoid entities without a known year." : "Sort by year if applicable."}`;
809
+ Ensure each entry is a different entity. ${dateRequired ? 'Sort by year. STRICTLY avoid entities without a known year.' : 'Sort by year if applicable.'}`;
867
810
 
868
811
  // console.log(`🤖 [Gemini] fetchPersonWorks Prompt for "${nodeName}":`, prompt);
869
812
 
870
- const rawText = await runJsonCompletion({
871
- system: SYSTEM_INSTRUCTION,
872
- user: prompt,
873
- timeoutMs: GEMINI_TIMEOUT_MS,
874
- attempts: 4,
875
- gemini: gemini
876
- ? () =>
877
- gemini.models.generateContent({
878
- model: getGeminiModel(),
879
- contents: prompt,
880
- config: {
881
- systemInstruction: SYSTEM_INSTRUCTION,
882
- responseMimeType: "application/json",
883
- responseSchema: {
884
- type: Type.OBJECT,
885
- properties: {
886
- works: {
887
- type: Type.ARRAY,
888
- items: {
889
- type: Type.OBJECT,
890
- properties: {
891
- entity: { type: Type.STRING },
892
- isAtomic: {
893
- type: Type.BOOLEAN,
894
- nullable: true,
895
- description: "True if atomic, false if composite",
896
- },
897
- wikipediaTitle: {
898
- type: Type.STRING,
899
- nullable: true,
900
- description:
901
- "Canonical English Wikipedia article title for this entity (use disambiguation parentheses when needed)",
902
- },
903
- type: { type: Type.STRING },
904
- description: {
905
- type: Type.STRING,
906
- nullable: true,
907
- description: "Short 1-sentence description",
908
- },
909
- role: { type: Type.STRING, nullable: true },
910
- year: {
911
- type: Type.INTEGER,
912
- nullable: true,
913
- description:
914
- "4-digit year (YYYY), required for events/works",
915
- },
916
- evidenceSnippet: {
917
- type: Type.STRING,
918
- description:
919
- "1 sentence evidence; if VERIFIED INFORMATION is provided, prefer verbatim from it",
920
- },
921
- evidencePageTitle: {
922
- type: Type.STRING,
923
- description:
924
- "Wikipedia page title where the snippet came from (usually the source)",
925
- },
926
- },
927
- required: [
928
- "entity",
929
- "type",
930
- "evidenceSnippet",
931
- "evidencePageTitle",
932
- ],
933
- },
934
- },
935
- },
936
- required: ["works"],
813
+ const makeApiCall = () => ai.models.generateContent({
814
+ model: getGeminiModel(),
815
+ contents: prompt,
816
+ config: {
817
+ systemInstruction: SYSTEM_INSTRUCTION,
818
+ responseMimeType: "application/json",
819
+ responseSchema: {
820
+ type: Type.OBJECT,
821
+ properties: {
822
+ works: {
823
+ type: Type.ARRAY,
824
+ items: {
825
+ type: Type.OBJECT,
826
+ properties: {
827
+ entity: { type: Type.STRING },
828
+ isAtomic: { type: Type.BOOLEAN, nullable: true, description: "True if atomic, false if composite" },
829
+ wikipediaTitle: { type: Type.STRING, nullable: true, description: "Canonical English Wikipedia article title for this entity (use disambiguation parentheses when needed)" },
830
+ type: { type: Type.STRING },
831
+ description: { type: Type.STRING, nullable: true, description: "Short 1-sentence description" },
832
+ role: { type: Type.STRING, nullable: true },
833
+ year: { type: Type.INTEGER, nullable: true, description: "4-digit year (YYYY), required for events/works" },
834
+ evidenceSnippet: { type: Type.STRING, description: "1 sentence evidence; if VERIFIED INFORMATION is provided, prefer verbatim from it" },
835
+ evidencePageTitle: { type: Type.STRING, description: "Wikipedia page title where the snippet came from (usually the source)" }
937
836
  },
938
- },
939
- })
940
- : undefined,
837
+ required: ["entity", "type", "evidenceSnippet", "evidencePageTitle"]
838
+ }
839
+ }
840
+ },
841
+ required: ["works"]
842
+ }
843
+ }
941
844
  });
942
- // console.log(`🤖 [Gemini] Raw response for "${nodeName}" (works):`, rawText);
943
- const text = cleanJson(rawText);
944
- if (!text) return { works: [] };
945
- const parsed = JSON.parse(text) as PersonWorksResponse;
946
- if (!Array.isArray(parsed.works)) parsed.works = [];
947
-
948
- // Some providers (notably Anthropic in this app) sometimes return an `entities` array for this endpoint
949
- // instead of `works`. Map it into the canonical `works` shape so the UI can actually render nodes.
950
- if (parsed.works.length === 0 && Array.isArray((parsed as any).entities)) {
951
- const entities = (parsed as any).entities as any[];
952
- parsed.works = entities
953
- .map((e) => {
954
- const wikiTitle =
955
- typeof e?.wikipediaTitle === "string" ? e.wikipediaTitle.trim() : "";
956
- const entity =
957
- typeof e?.entity === "string" && e.entity.trim()
958
- ? e.entity.trim()
959
- : wikiTitle;
960
- if (!entity) return null;
961
- return {
962
- entity,
963
- wikipediaTitle: wikiTitle || undefined,
964
- type: typeof e?.type === "string" && e.type.trim() ? e.type.trim() : (compositeType || "Event"),
965
- description:
966
- typeof e?.description === "string" ? e.description : "",
967
- role: typeof e?.role === "string" ? e.role : "",
968
- year:
969
- e?.year === null || e?.year === undefined || isNaN(Number(e.year))
970
- ? (undefined as any)
971
- : Number(e.year),
972
- isAtomic: false,
973
- evidenceSnippet:
974
- typeof e?.evidenceSnippet === "string" ? e.evidenceSnippet : "",
975
- evidencePageTitle:
976
- typeof e?.evidencePageTitle === "string"
977
- ? e.evidencePageTitle
978
- : nodeName,
979
- } as PersonWork;
980
- })
981
- .filter(Boolean) as PersonWork[];
982
- }
983
845
 
984
- const hasValidYear = (w: PersonWork) =>
985
- w.year !== null && w.year !== undefined && !isNaN(Number(w.year));
986
-
987
- // OpenAI-style models sometimes use "name" instead of "entity"; keep downstream filters working.
988
- parsed.works = parsed.works.map((w: any) => {
989
- const entity =
990
- typeof w.entity === "string" && w.entity.trim()
991
- ? w.entity.trim()
992
- : typeof w.wikipediaTitle === "string" && w.wikipediaTitle.trim()
993
- ? w.wikipediaTitle.trim()
994
- : typeof w.name === "string" && w.name.trim()
995
- ? w.name.trim()
996
- : "";
997
- return { ...w, entity };
998
- });
846
+ const response = await withRetry(
847
+ () => withTimeout(makeApiCall(), GEMINI_TIMEOUT_MS, "Gemini API request timed out"),
848
+ 4,
849
+ 1000
850
+ );
999
851
 
1000
- // Force correct bipartite type regardless of LLM slip-ups
1001
- if (dateRequired) {
1002
- const withYear = parsed.works.filter(hasValidYear);
1003
- // Strict year filter avoids junk, but models often omit years — then everyone fails (e.g. "Martin Scorsese" empty graph).
1004
- if (withYear.length > 0) {
1005
- parsed.works = withYear;
1006
- } else if (parsed.works.length > 0) {
1007
- console.warn(
1008
- `[fetchPersonWorks] "${nodeName}": no entries with valid year; keeping ${parsed.works.length} without year filter`,
1009
- );
852
+ const rawText = getResponseText(response);
853
+ // console.log(`🤖 [Gemini] Raw response for "${nodeName}" (works):`, rawText);
854
+ const parsed = parseJsonFromModelText(rawText) as PersonWorksResponse | null;
855
+ if (!parsed || !Array.isArray(parsed.works)) return { works: [] };
856
+ // Force correct bipartite type regardless of LLM slip-ups; drop junk entity names
857
+ if (parsed.works) {
858
+ parsed.works = parsed.works.filter(w => isValidEntityName(w.entity));
859
+ if (dateRequired) {
860
+ parsed.works = parsed.works.filter(w => w.year !== null && w.year !== undefined && !isNaN(Number(w.year)));
1010
861
  }
862
+ parsed.works = parsed.works.map(w => ({
863
+ ...w,
864
+ isAtomic: false // In fetchPersonWorks, the source is ATOMIC, so all results MUST be COMPOSITE (false)
865
+ }));
1011
866
  }
1012
- parsed.works = parsed.works.map((w) => ({
1013
- ...w,
1014
- isAtomic: false, // In fetchPersonWorks, the source is ATOMIC, so all results MUST be COMPOSITE (false)
1015
- }));
1016
867
  return parsed;
1017
868
  } catch (error) {
1018
869
  console.error("Gemini API Error (Person Works):", error);
@@ -1020,31 +871,26 @@ export const fetchPersonWorks = async (
1020
871
  }
1021
872
  };
1022
873
 
1023
- export const fetchConnectionPath = async (
1024
- start: string,
1025
- end: string,
1026
- context?: { startWiki?: string; endWiki?: string },
1027
- ): Promise<PathResponse> => {
874
+ export const fetchConnectionPath = async (start: string, end: string, context?: { startWiki?: string; endWiki?: string }): Promise<PathResponse> => {
1028
875
  if (shouldProxy()) {
1029
876
  return callAiProxy("/api/ai/path", { start, end, context });
1030
877
  }
1031
878
 
1032
- try {
1033
- const apiKey = await getLlmApiKey();
1034
- if (!apiKey) {
1035
- console.error("[Gemini] fetchConnectionPath: No API key found");
1036
- return { path: [], found: false };
879
+ const apiKey = await getApiKey();
880
+ if (!apiKey) {
881
+ if (process.env.NODE_ENV !== "production") {
882
+ console.warn("[Gemini] fetchConnectionPath: No API key — returning empty path");
1037
883
  }
884
+ return { path: [], found: false };
885
+ }
1038
886
 
1039
- const useGemini = getLlmProvider() === "gemini";
1040
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
887
+ const ai = new GoogleGenAI({ apiKey });
1041
888
 
1042
- const wikiPrompt =
1043
- context?.startWiki || context?.endWiki
1044
- ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${context?.startWiki ? `[${start}]: ${context.startWiki}\n` : ""}${context?.endWiki ? `[${end}]: ${context.endWiki}\n` : ""}`
1045
- : "";
889
+ const wikiPrompt = (context?.startWiki || context?.endWiki)
890
+ ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${context?.startWiki ? `[${start}]: ${context.startWiki}\n` : ''}${context?.endWiki ? `[${end}]: ${context.endWiki}\n` : ''}`
891
+ : "";
1046
892
 
1047
- const prompt = `Find a connection path between "${start}" and "${end}".
893
+ const prompt = `Find a connection path between "${start}" and "${end}".
1048
894
  ${wikiPrompt}
1049
895
 
1050
896
  Your goal is to find the most direct and historically significant connection path.
@@ -1096,61 +942,44 @@ export const fetchConnectionPath = async (
1096
942
  ]
1097
943
  }`;
1098
944
 
1099
- const text = await runJsonCompletion({
1100
- system: SYSTEM_INSTRUCTION,
1101
- user: prompt,
1102
- timeoutMs: 45000,
1103
- attempts: 4,
1104
- gemini: gemini
1105
- ? () =>
1106
- gemini.models.generateContent({
1107
- model: getGeminiModel(),
1108
- contents: prompt,
1109
- config: {
1110
- systemInstruction: SYSTEM_INSTRUCTION,
1111
- responseMimeType: "application/json",
1112
- responseSchema: {
1113
- type: Type.OBJECT,
1114
- properties: {
1115
- path: {
1116
- type: Type.ARRAY,
1117
- items: {
1118
- type: Type.OBJECT,
1119
- properties: {
1120
- id: { type: Type.STRING },
1121
- type: { type: Type.STRING },
1122
- description: { type: Type.STRING },
1123
- justification: {
1124
- type: Type.STRING,
1125
- description:
1126
- "Relationship to the PREVIOUS node in the chain",
1127
- },
1128
- year: {
1129
- type: Type.INTEGER,
1130
- nullable: true,
1131
- description:
1132
- "Year of occurrence/creation (Required for Events)",
1133
- },
1134
- },
1135
- required: [
1136
- "id",
1137
- "type",
1138
- "description",
1139
- "justification",
1140
- ],
1141
- },
1142
- },
1143
- },
1144
- required: ["path"],
945
+ try {
946
+ const response = await withTimeout(ai.models.generateContent({
947
+ model: getGeminiModel(),
948
+ contents: prompt,
949
+ config: {
950
+ systemInstruction: SYSTEM_INSTRUCTION,
951
+ responseMimeType: "application/json",
952
+ responseSchema: {
953
+ type: Type.OBJECT,
954
+ properties: {
955
+ path: {
956
+ type: Type.ARRAY,
957
+ items: {
958
+ type: Type.OBJECT,
959
+ properties: {
960
+ id: { type: Type.STRING },
961
+ type: { type: Type.STRING },
962
+ description: { type: Type.STRING },
963
+ justification: { type: Type.STRING, description: "Relationship to the PREVIOUS node in the chain" },
964
+ year: { type: Type.INTEGER, nullable: true, description: "Year of occurrence/creation (Required for Events)" }
1145
965
  },
1146
- },
1147
- })
1148
- : undefined,
1149
- });
1150
- const json = JSON.parse(cleanJson(text));
966
+ required: ["id", "type", "description", "justification"]
967
+ }
968
+ }
969
+ },
970
+ required: ["path"]
971
+ }
972
+ }
973
+ }), 45000, "Pathfinding timed out");
974
+
975
+ const text = getResponseText(response);
976
+ const json = parseJsonFromModelText(text) as { path?: PathResponse["path"] } | null;
977
+ if (!json || !Array.isArray(json.path)) {
978
+ return { path: [], found: false };
979
+ }
1151
980
 
1152
981
  // Ensure the path starts with the start node and ends with the end node
1153
- if (json.path && json.path.length > 0) {
982
+ if (json.path.length > 0) {
1154
983
  const first = json.path[0].id.toLowerCase();
1155
984
  const last = json.path[json.path.length - 1].id.toLowerCase();
1156
985
  const startLow = start.toLowerCase();
@@ -1162,8 +991,7 @@ export const fetchConnectionPath = async (
1162
991
  id: start,
1163
992
  type: "Start",
1164
993
  description: context?.startWiki?.substring(0, 100) || "Start node",
1165
- justification: "Start of path",
1166
- year: null,
994
+ justification: "Start of path"
1167
995
  });
1168
996
  }
1169
997
  if (!last.includes(endLow) && !endLow.includes(last)) {
@@ -1171,33 +999,28 @@ export const fetchConnectionPath = async (
1171
999
  id: end,
1172
1000
  type: "End",
1173
1001
  description: context?.endWiki?.substring(0, 100) || "End node",
1174
- justification: "Destination",
1175
- year: null,
1002
+ justification: "Destination"
1176
1003
  });
1177
1004
  }
1178
1005
  }
1179
1006
 
1180
- return json as PathResponse;
1007
+ return { path: json.path, found: json.path.length > 0 };
1181
1008
  } catch (error) {
1182
1009
  console.error("Gemini Pathfinding Error:", error);
1183
1010
  return { path: [], found: false };
1184
1011
  }
1185
1012
  };
1186
1013
 
1187
- export const findWikipediaTitle = async (
1188
- name: string,
1189
- description?: string,
1190
- ): Promise<{ title: string; imageHint?: string } | null> => {
1014
+ export const findWikipediaTitle = async (name: string, description?: string): Promise<{ title: string; imageHint?: string } | null> => {
1191
1015
  if (shouldProxy()) {
1192
1016
  return callAiProxy("/api/ai/title", { name, description });
1193
1017
  }
1194
1018
 
1195
- const apiKey = await getLlmApiKey();
1019
+ const apiKey = await getApiKey();
1196
1020
  if (!apiKey) return null;
1197
- const useGemini = getLlmProvider() === "gemini";
1198
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
1021
+ const ai = new GoogleGenAI({ apiKey });
1199
1022
 
1200
- const prompt = `Find the exact English Wikipedia article title for "${name}"${description ? ` described as "${description}"` : ""}.
1023
+ const prompt = `Find the exact English Wikipedia article title for "${name}"${description ? ` described as "${description}"` : ''}.
1201
1024
  Also, if you know a specific Wikimedia Commons filename for a good portrait of this person/thing, include it.
1202
1025
 
1203
1026
  Return JSON:
@@ -1207,33 +1030,28 @@ export const findWikipediaTitle = async (
1207
1030
  }`;
1208
1031
 
1209
1032
  try {
1210
- const text = await runJsonCompletion({
1211
- user: prompt,
1212
- timeoutMs: 10000,
1213
- attempts: 2,
1214
- gemini: gemini
1215
- ? () =>
1216
- gemini.models.generateContent({
1217
- model: getGeminiModel(),
1218
- contents: prompt,
1219
- config: {
1220
- responseMimeType: "application/json",
1221
- responseSchema: {
1222
- type: Type.OBJECT,
1223
- properties: {
1224
- title: { type: Type.STRING },
1225
- imageHint: { type: Type.STRING, nullable: true },
1226
- },
1227
- required: ["title"],
1228
- },
1229
- },
1230
- })
1231
- : undefined,
1232
- });
1233
- const json = JSON.parse(cleanJson(text));
1033
+ const response = await withTimeout(ai.models.generateContent({
1034
+ model: getGeminiModel(),
1035
+ contents: prompt,
1036
+ config: {
1037
+ responseMimeType: "application/json",
1038
+ responseSchema: {
1039
+ type: Type.OBJECT,
1040
+ properties: {
1041
+ title: { type: Type.STRING },
1042
+ imageHint: { type: Type.STRING, nullable: true }
1043
+ },
1044
+ required: ["title"]
1045
+ }
1046
+ }
1047
+ }), 10000, "Title lookup timed out");
1048
+
1049
+ const text = getResponseText(response);
1050
+ const json = parseJsonFromModelText(text) as { title?: string; imageHint?: string } | null;
1051
+ if (!json || typeof json.title !== "string" || !json.title.trim()) return null;
1234
1052
  return {
1235
1053
  title: json.title,
1236
- imageHint: json.imageHint,
1054
+ imageHint: json.imageHint
1237
1055
  };
1238
1056
  } catch (e) {
1239
1057
  // console.warn("AI title lookup failed", e);
@@ -1243,17 +1061,12 @@ export const findWikipediaTitle = async (
1243
1061
 
1244
1062
  // Optional: grounded lookup for org leadership using Google Search tool.
1245
1063
  // NOTE: This cannot use responseSchema/responseMimeType; we parse JSON from text.
1246
- export const fetchOrgKeyPeopleBlockViaSearch = async (
1247
- orgName: string,
1248
- ): Promise<string | null> => {
1064
+ export const fetchOrgKeyPeopleBlockViaSearch = async (orgName: string): Promise<string | null> => {
1249
1065
  if (shouldProxy()) {
1250
1066
  return callAiProxy("/api/ai/search-org", { orgName });
1251
1067
  }
1252
1068
 
1253
- // Google Search grounding is Gemini-only in this codebase.
1254
- if (getLlmProvider() !== "gemini") return null;
1255
-
1256
- const apiKey = await getLlmApiKey();
1069
+ const apiKey = await getApiKey();
1257
1070
  if (!apiKey) return null;
1258
1071
 
1259
1072
  const name = String(orgName || "").trim();
@@ -1284,21 +1097,19 @@ Rules:
1284
1097
  model: getGeminiModel(),
1285
1098
  contents: prompt,
1286
1099
  config: {
1287
- systemInstruction:
1288
- "You are a careful research assistant. Use Google Search for grounding and do not invent facts.",
1289
- tools: [{ googleSearch: {} }],
1290
- },
1100
+ systemInstruction: "You are a careful research assistant. Use Google Search for grounding and do not invent facts.",
1101
+ tools: [{ googleSearch: {} }]
1102
+ }
1291
1103
  }),
1292
1104
  20000,
1293
- "Org key-people search timed out",
1105
+ "Org key-people search timed out"
1294
1106
  ),
1295
1107
  4,
1296
- 1000,
1108
+ 1000
1297
1109
  );
1298
1110
 
1299
- const text = cleanJson(getResponseText(response));
1300
- if (!text) return null;
1301
- const json = JSON.parse(text) as any;
1111
+ const json = parseJsonFromModelText(getResponseText(response)) as { founders?: unknown; keyPeople?: unknown } | null;
1112
+ if (!json || typeof json !== "object" || Array.isArray(json)) return null;
1302
1113
  const founders = Array.isArray(json?.founders) ? json.founders : [];
1303
1114
  const keyPeople = Array.isArray(json?.keyPeople) ? json.keyPeople : [];
1304
1115
 
@@ -1309,7 +1120,7 @@ Rules:
1309
1120
  name: String(x.name).trim(),
1310
1121
  evidence: x?.evidence ? String(x.evidence).trim() : "",
1311
1122
  sourceTitle: x?.sourceTitle ? String(x.sourceTitle).trim() : "",
1312
- sourceUrl: x?.sourceUrl ? String(x.sourceUrl).trim() : "",
1123
+ sourceUrl: x?.sourceUrl ? String(x.sourceUrl).trim() : ""
1313
1124
  }))
1314
1125
  .filter((x: any) => x.name);
1315
1126
 
@@ -1321,7 +1132,7 @@ Rules:
1321
1132
  role: x?.role ? String(x.role).trim() : "",
1322
1133
  evidence: x?.evidence ? String(x.evidence).trim() : "",
1323
1134
  sourceTitle: x?.sourceTitle ? String(x.sourceTitle).trim() : "",
1324
- sourceUrl: x?.sourceUrl ? String(x.sourceUrl).trim() : "",
1135
+ sourceUrl: x?.sourceUrl ? String(x.sourceUrl).trim() : ""
1325
1136
  }))
1326
1137
  .filter((x: any) => x.name);
1327
1138
 
@@ -1329,28 +1140,24 @@ Rules:
1329
1140
 
1330
1141
  const lines: string[] = [];
1331
1142
  if (f.length) {
1332
- lines.push(`Founders: ${f.map((x) => x.name).join(", ")}`);
1143
+ lines.push(`Founders: ${f.map((x: { name: string }) => x.name).join(", ")}`);
1333
1144
  }
1334
1145
  if (kp.length) {
1335
1146
  lines.push(
1336
1147
  `Key People: ${kp
1337
- .map((x) => (x.role ? `${x.name} (${x.role})` : x.name))
1338
- .join(", ")}`,
1148
+ .map((x: { name: string; role: string }) => (x.role ? `${x.name} (${x.role})` : x.name))
1149
+ .join(", ")}`
1339
1150
  );
1340
1151
  }
1341
1152
  const sources = [...f, ...kp]
1342
- .map((x) =>
1343
- x.sourceUrl ? `${x.sourceTitle || "Source"} — ${x.sourceUrl}` : "",
1344
- )
1153
+ .map((x: { sourceUrl?: string; sourceTitle?: string }) => (x.sourceUrl ? `${x.sourceTitle || "Source"} — ${x.sourceUrl}` : ""))
1345
1154
  .filter(Boolean);
1346
1155
  const uniqueSources = Array.from(new Set(sources)).slice(0, 8);
1347
1156
 
1348
1157
  return [
1349
1158
  `GOOGLE_SEARCH_GROUNDED (for "${name}")`,
1350
- ...lines.map((l) => `- ${l}`),
1351
- ...(uniqueSources.length
1352
- ? ["Sources:", ...uniqueSources.map((s) => `- ${s}`)]
1353
- : []),
1159
+ ...lines.map(l => `- ${l}`),
1160
+ ...(uniqueSources.length ? ["Sources:", ...uniqueSources.map(s => `- ${s}`)] : [])
1354
1161
  ].join("\n");
1355
1162
  } catch (e) {
1356
1163
  // console.warn("Org key-people search failed:", name, e);