@johndimm/constellations 1.0.1 → 1.0.2

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 (38) hide show
  1. package/App.tsx +352 -70
  2. package/FullPageConstellations.tsx +7 -4
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +69 -29
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +46 -371
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +1 -0
  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 +61 -229
  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 +57 -19
  20. package/host.ts +1 -1
  21. package/index.css +17 -3
  22. package/package.json +2 -1
  23. package/services/aiService.ts +23 -0
  24. package/services/aiUtils.ts +216 -207
  25. package/services/cacheService.ts +1 -0
  26. package/services/crossrefService.ts +1 -0
  27. package/services/deepseekService.ts +467 -0
  28. package/services/geminiService.ts +532 -733
  29. package/services/graphUtils.ts +128 -18
  30. package/services/imageService.ts +18 -0
  31. package/services/openAlexService.ts +1 -0
  32. package/services/resolveImageForTitle.ts +458 -0
  33. package/services/wikipediaImage.ts +1 -0
  34. package/services/wikipediaService.ts +56 -46
  35. package/types.ts +3 -0
  36. package/utils/evidenceUtils.ts +1 -0
  37. package/utils/graphLogicUtils.ts +1 -0
  38. 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 } 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)
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,7 +147,7 @@ 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();
@@ -237,9 +172,94 @@ export function defaultStartPairResult(reason: string): {
237
172
  };
238
173
  }
239
174
 
175
+ /** Messy pasted now-playing / YouTube text; short single-line queries skip the extra Gemini call. */
176
+ function rawTermNeedsMusicEntityExtract(raw: string): boolean {
177
+ const t = raw.trim();
178
+ if (!t) return false;
179
+ if (t.length > 220) return true;
180
+ if (t.includes("\n")) return true;
181
+ if (/https?:\/\/(www\.)?(youtube\.com|youtu\.be)\b/i.test(t)) return true;
182
+ if (/\b(feat\.|ft\.|official\s+video|official\s+audio)\b/i.test(t)) return true;
183
+ return false;
184
+ }
185
+
186
+ /**
187
+ * Given raw pasted text (YouTube title, channel name, multi-line description),
188
+ * ask the LLM to pick the best graph starting node — using world knowledge to
189
+ * disambiguate and choose the most meaningful hub entity. Falls back to raw input on failure.
190
+ */
191
+ export const extractMusicEntity = async (raw: string): Promise<string> => {
192
+ const trimmed = raw.trim();
193
+ if (!trimmed) return trimmed;
194
+
195
+ const apiKey = await getApiKey();
196
+ if (!apiKey) return trimmed;
197
+
198
+ const ai = new GoogleGenAI({ apiKey });
199
+ 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.
200
+
201
+ 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.
202
+
203
+ Guidelines:
204
+ - 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.
205
+ - 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.
206
+ - For classical music, prefer composer over performer; include the work title (e.g. "Fauré: Sicilienne").
207
+ - 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.
208
+ - Ignore YouTube channel usernames (random strings, channel handles), record labels, streaming platform names, and bare years.
209
+
210
+ Examples:
211
+ - "Gautier Capuçon plays Fauré: Sicilienne, Op. 78\\nWarner Classics" → "Fauré: Sicilienne, Op. 78"
212
+ - "Alban Berg- Lyric Suite Part 3\\nplayingmusiconmars1926" → "Alban Berg: Lyric Suite"
213
+ - "György Ligeti - Mathieu Romano\\nLux Aeterna1966" → "György Ligeti: Lux Aeterna"
214
+ - "Ravel : Pavane (Orchestre national de France / Dalia Stasevska)\\nFrance Musique concerts1899" → "Ravel: Pavane pour une infante défunte"
215
+ - "Vaughan Williams ~ The Lark Ascending" → "Vaughan Williams: The Lark Ascending"
216
+ - "Hildegard von Bingen, O rubor sanguinis (with score)\\nhuakinthoi" → "Hildegard von Bingen: O rubor sanguinis"
217
+ - "Around the World / Harder, Better, Faster, Stronger\\nDaft Punk" → "Daft Punk"
218
+ - "One More Time\\nDaft Punk" → "Daft Punk: One More Time"
219
+ - "The Ends\\nSeth David2024" → "Seth David: The Ends"
220
+ - "10% (feat. Kali Uchis)\\nKAYTRANADA, Kali Uchis2019" → "KAYTRANADA: 10% (feat. Kali Uchis)"
221
+ - "Glenn Gould" → "Glenn Gould"
222
+ - "Bach: Goldberg Variations" → "Bach: Goldberg Variations"
223
+
224
+ Raw text:
225
+ """
226
+ ${trimmed}
227
+ """
228
+
229
+ Return JSON: { "entity": "<extracted entity name>" }`;
230
+
231
+ try {
232
+ const response = await withTimeout(
233
+ ai.models.generateContent({
234
+ model: getGeminiModelClassify(),
235
+ contents: prompt,
236
+ config: {
237
+ responseMimeType: "application/json",
238
+ responseSchema: {
239
+ type: Type.OBJECT,
240
+ properties: { entity: { type: Type.STRING } },
241
+ required: ["entity"]
242
+ }
243
+ }
244
+ }),
245
+ 8000,
246
+ "extractMusicEntity timed out"
247
+ );
248
+ const parsed = parseJsonFromModelText(getResponseText(response)) as { entity?: string } | null;
249
+ const entity = parsed?.entity?.trim();
250
+ if (entity && entity.length > 1) {
251
+ if (entity !== trimmed) console.log(`[extractMusicEntity] "${trimmed}" → "${entity}"`);
252
+ return entity;
253
+ }
254
+ } catch (e) {
255
+ console.warn("[extractMusicEntity] failed, using raw input:", String(e).slice(0, 120));
256
+ }
257
+ return trimmed;
258
+ };
259
+
240
260
  export const classifyStartPair = async (
241
- term: string,
242
- wikiContext?: string,
261
+ rawTerm: string,
262
+ wikiContext?: string
243
263
  ): Promise<{
244
264
  type: string;
245
265
  description: string;
@@ -248,56 +268,71 @@ export const classifyStartPair = async (
248
268
  compositeType: string;
249
269
  reasoning: string;
250
270
  }> => {
251
- if (shouldProxy()) {
252
- return callAiProxy("/api/ai/classify-start", { term, wikiContext });
271
+ // With `VITE_CACHE_URL`, classification hits the cache server first. Skip client-side
272
+ // `extractMusicEntity` (music/YouTube-specific Gemini call) it adds a full round-trip and
273
+ // the wrong prompt for films / general graph seeds like "The Godfather".
274
+ const cacheUrl = getEnvCacheUrl();
275
+ const proxy =
276
+ typeof window !== "undefined" &&
277
+ !(window as any).__PRERENDER_INJECTED &&
278
+ !!cacheUrl;
279
+ if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
280
+ let cacheHost = "";
281
+ try {
282
+ if (cacheUrl) cacheHost = new URL(cacheUrl).host;
283
+ } catch {
284
+ cacheHost = "(invalid)";
285
+ }
286
+ console.log("[Constellations]", "classifyStartPair", {
287
+ term: rawTerm.trim().slice(0, 80),
288
+ hasWikiContext: !!wikiContext,
289
+ useProxy: proxy,
290
+ cacheHost: cacheHost || null,
291
+ });
292
+ }
293
+ if (proxy) {
294
+ return callAiProxy("/api/ai/classify-start", { term: rawTerm.trim(), wikiContext });
253
295
  }
254
296
 
255
- const apiKey = await getLlmApiKey();
297
+ const needsMusic = rawTermNeedsMusicEntityExtract(rawTerm);
298
+ if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
299
+ console.log("[Constellations]", "classifyStartPair local Gemini path", { needsMusicExtract: needsMusic });
300
+ }
301
+ const term = needsMusic ? await extractMusicEntity(rawTerm) : rawTerm.trim();
302
+
303
+ const apiKey = await getApiKey();
256
304
  // String-level safety heuristic (no Wikipedia required):
257
305
  // Disambiguated titles like "Discover (Daft Punk album)" must never be treated as Person.
258
306
  // Treat common work/media parentheticals as Composite/Event in the temporary Person↔Event model.
259
307
  const t = term.trim();
260
308
  // Academic heuristics (no model required):
261
309
  // 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
- ) {
310
+ if (/\b10\.\d{4,9}\/\S+\b/i.test(t) || /\barxiv\b|arxiv:\s*\d{4}\.\d{4,5}/i.test(t)) {
266
311
  return {
267
312
  type: "Paper",
268
313
  description: "",
269
314
  isAtomic: false,
270
315
  atomicType: "Author",
271
316
  compositeType: "Paper",
272
- reasoning:
273
- "Seed looks like an academic paper identifier (DOI/arXiv); selecting Author↔Paper.",
317
+ reasoning: "Seed looks like an academic paper identifier (DOI/arXiv); selecting Author↔Paper."
274
318
  };
275
319
  }
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
- ) {
320
+ if (/\((album|song|single|film|movie|tv series|television series|book|novel|painting|sculpture|artwork|opera|symphony)\)/i.test(t)) {
281
321
  return {
282
322
  type: "Event",
283
323
  description: "",
284
324
  isAtomic: false,
285
325
  atomicType: "Person",
286
326
  compositeType: "Event",
287
- reasoning:
288
- "Title contains an explicit work/media disambiguator (e.g., '(album)'); treating it as Composite in Person↔Event.",
327
+ reasoning: "Title contains an explicit work/media disambiguator (e.g., '(album)'); treating it as Composite in Person↔Event."
289
328
  };
290
329
  }
291
330
 
331
+
292
332
  if (!apiKey) {
293
- return defaultStartPairResult(
294
- "No API key available; defaulting to Person↔Event.",
295
- );
333
+ return defaultStartPairResult("No API key available; defaulting to Person↔Event.");
296
334
  }
297
335
 
298
- const useGemini = getLlmProvider() === "gemini";
299
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
300
-
301
336
  const prompt = `Choose the most appropriate bipartite pair for this session based on the input: "${term}".
302
337
 
303
338
  You may identify other valid bipartite structures if appropriate for "${term}".
@@ -308,64 +343,60 @@ Rules:
308
343
  - If "${term}" is an organization/institution/band, it is ALWAYS COMPOSITE.
309
344
  - If "${term}" looks like an academic paper or DOI/arXiv, it is COMPOSITE (use Author ↔ Paper).
310
345
  - If "${term}" is a very famous person, it is ATOMIC even if they have works.
346
+ - 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
347
  `;
312
348
 
313
349
  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,
350
+ const ai = new GoogleGenAI({ apiKey });
351
+
352
+ const makeApiCall = () => ai.models.generateContent({
353
+ model: getGeminiModelClassify(),
354
+ contents: prompt,
355
+ config: {
356
+ responseMimeType: "application/json",
357
+ responseSchema: {
358
+ type: Type.OBJECT,
359
+ properties: {
360
+ type: { type: Type.STRING },
361
+ description: { type: Type.STRING },
362
+ isAtomic: { type: Type.BOOLEAN },
363
+ atomicType: { type: Type.STRING },
364
+ compositeType: { type: Type.STRING },
365
+ reasoning: { type: Type.STRING }
366
+ },
367
+ required: ["type", "isAtomic", "atomicType", "compositeType"]
368
+ }
369
+ }
340
370
  });
341
- // console.log(`🤖 [Gemini] Raw Classify-Start response for "${term}":`, rawText);
342
- const text = cleanJson(rawText);
343
- const json = text ? JSON.parse(text) : {};
344
371
 
372
+ const response = await withRetry(
373
+ () => withTimeout(makeApiCall(), CLASSIFY_TIMEOUT_MS, "Start-pair classification timed out"),
374
+ 3,
375
+ 1000
376
+ );
377
+
378
+ const rawText = getResponseText(response);
379
+ const parsed = parseJsonFromModelText(rawText);
380
+ const json = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
381
+
382
+ const s = (v: unknown, fallback: string) => (typeof v === "string" && v ? v : fallback);
345
383
  return {
346
- type: json.type || "Event",
347
- description: json.description || "",
384
+ type: s(json.type, "Event"),
385
+ description: s(json.description, ""),
348
386
  isAtomic: !!json.isAtomic,
349
- atomicType: json.atomicType || "Person",
350
- compositeType: json.compositeType || "Event",
351
- reasoning: json.reasoning || "",
387
+ atomicType: s(json.atomicType, "Person"),
388
+ compositeType: s(json.compositeType, "Event"),
389
+ reasoning: s(json.reasoning, "")
352
390
  };
353
391
  } catch (e: any) {
354
- const msg = String(e?.message || e || "");
355
- console.warn(
356
- `[classifyStartPair] failed for "${term}":`,
357
- msg.slice(0, 200),
358
- );
392
+ console.warn("[classifyStartPair]", term, String(e?.message || e).slice(0, 200));
359
393
  return defaultStartPairResult(
360
- "Classification API unavailable (quota, rate limit, or error); defaulting to Person↔Event.",
394
+ "Classification API unavailable (quota/rate limit or error); defaulting to Person↔Event."
361
395
  );
362
396
  }
363
397
  };
364
398
 
365
- export const classifyEntity = async (
366
- term: string,
367
- wikiContext?: string,
368
- ): Promise<{
399
+ export const classifyEntity = async (term: string, wikiContext?: string): Promise<{
369
400
  type: string;
370
401
  description: string;
371
402
  isAtomic: boolean;
@@ -377,33 +408,29 @@ export const classifyEntity = async (
377
408
  return callAiProxy("/api/ai/classify", { term, wikiContext });
378
409
  }
379
410
 
380
- const apiKey = await getLlmApiKey();
411
+ const apiKey = await getApiKey();
381
412
  const normalized = term.trim().toLowerCase();
382
413
 
383
414
  // String-level safety heuristic (no Wikipedia required):
384
415
  // 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
- ) {
416
+ if (/\((album|song|single|film|movie|tv series|television series|book|novel|painting|sculpture|artwork|opera|symphony)\)/i.test(term.trim())) {
390
417
  return {
391
418
  type: "Event",
392
419
  description: "",
393
420
  isAtomic: false,
394
421
  atomicType: "Person",
395
422
  compositeType: "Event",
396
- reasoning:
397
- "Title contains an explicit work/media disambiguator (e.g., '(album)'); treating it as Composite in Person↔Event.",
423
+ reasoning: "Title contains an explicit work/media disambiguator (e.g., '(album)'); treating it as Composite in Person↔Event."
398
424
  };
399
425
  }
400
426
 
427
+
401
428
  if (!apiKey) {
402
429
  console.error("❌ [Gemini] classifyEntity: No API key found");
403
- return { type: "Event", description: "", isAtomic: false };
430
+ return { type: 'Event', description: '', isAtomic: false };
404
431
  }
405
- const useGemini = getLlmProvider() === "gemini";
406
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
432
+ // console.log(`🧪 [Gemini] classify start`, { term, timeoutMs: CLASSIFY_TIMEOUT_MS });
433
+ const ai = new GoogleGenAI({ apiKey });
407
434
 
408
435
  const wikiPrompt = wikiContext
409
436
  ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
@@ -437,52 +464,51 @@ export const classifyEntity = async (
437
464
 
438
465
  // console.log("🤖 [Gemini] Classify Prompt:", prompt);
439
466
 
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,
467
+ const makeApiCall = () => ai.models.generateContent({
468
+ model: getGeminiModelClassify(),
469
+ contents: prompt,
470
+ config: {
471
+ responseMimeType: "application/json",
472
+ responseSchema: {
473
+ type: Type.OBJECT,
474
+ properties: {
475
+ type: { type: Type.STRING },
476
+ description: { type: Type.STRING, description: "Short 1-sentence description" },
477
+ isAtomic: { type: Type.BOOLEAN },
478
+ atomicType: { type: Type.STRING },
479
+ compositeType: { type: Type.STRING },
480
+ reasoning: { type: Type.STRING }
481
+ },
482
+ required: ["type", "isAtomic", "atomicType", "compositeType"]
483
+ }
484
+ }
469
485
  });
486
+
487
+ const response = await withRetry(
488
+ () => withTimeout(makeApiCall(), CLASSIFY_TIMEOUT_MS, "Classification timed out"),
489
+ 3,
490
+ 1000
491
+ );
492
+
493
+ const rawText = getResponseText(response);
470
494
  // console.log(`🤖 [Gemini] Raw Classify response for "${term}":`, rawText);
471
- const text = cleanJson(rawText);
495
+ const json = parseJsonFromModelText(rawText);
472
496
  // console.log("Classify response text:", text);
473
- if (!text) return { type: "Event", description: "", isAtomic: false };
474
- const json = JSON.parse(text);
497
+ if (!json || typeof json !== "object" || Array.isArray(json)) {
498
+ return { type: 'Event', description: '', isAtomic: false };
499
+ }
500
+ const o = json as Record<string, unknown>;
475
501
  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,
502
+ type: (o.type as string) || 'Event',
503
+ description: (o.description as string) || '',
504
+ isAtomic: !!o.isAtomic,
505
+ atomicType: o.atomicType as string | undefined,
506
+ compositeType: o.compositeType as string | undefined,
507
+ reasoning: o.reasoning as string | undefined
482
508
  };
483
509
  } catch (error) {
484
510
  // console.warn("Classification failed, defaulting to Event:", error);
485
- return { type: "Event", description: "", isAtomic: false };
511
+ return { type: 'Event', description: '', isAtomic: false };
486
512
  }
487
513
  };
488
514
 
@@ -494,95 +520,84 @@ export const fetchConnections = async (
494
520
  wikipediaId?: string,
495
521
  atomicType?: string,
496
522
  compositeType?: string,
497
- mentioningPageTitles?: string[],
523
+ mentioningPageTitles?: string[]
498
524
  ): Promise<GeminiResponse> => {
499
525
  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
- });
526
+ return callAiProxy("/api/ai/connections", { nodeName, context, excludeNodes, wikiContext, wikipediaId, atomicType, compositeType, mentioningPageTitles });
510
527
  }
511
528
 
512
- try {
513
- const apiKey = await getLlmApiKey();
514
- if (!apiKey) {
515
- console.error("[Gemini] fetchConnections: No API key found");
516
- return { people: [] };
529
+ const apiKey = await getApiKey();
530
+ if (!apiKey) {
531
+ if (process.env.NODE_ENV !== "production") {
532
+ console.warn("[Gemini] fetchConnections: No API key — returning empty graph expansion");
517
533
  }
534
+ return { people: [] };
535
+ }
518
536
 
519
- const useGemini = getLlmProvider() === "gemini";
520
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
537
+ const ai = new GoogleGenAI({ apiKey });
521
538
 
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}.`;
539
+ const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
540
+ const contextualPrompt = context
541
+ ? `Analyze: "${nodeName}"${wikiIdStr} specifically in the context of "${context}".`
542
+ : `Analyze: "${nodeName}"${wikiIdStr}.`;
526
543
 
527
- const wikiPrompt = wikiContext
528
- ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
529
- : "";
544
+ const wikiPrompt = wikiContext
545
+ ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
546
+ : "";
547
+
548
+ const excludePrompt = excludeNodes.length > 0
549
+ ? `\nDO NOT include the following already known connections: ${JSON.stringify(excludeNodes)}. Find NEW high-impact connections.`
550
+ : "";
551
+
552
+ const mentionPrompt = mentioningPageTitles && mentioningPageTitles.length > 0
553
+ ? `\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.`
554
+ : "";
530
555
 
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.
556
+ const atomicLabel = atomicType || "ATOMIC entity";
557
+ const compositeLabel = compositeType || "COMPOSITE entity";
558
+ const personOnlyRule =
559
+ (atomicType || "").trim().toLowerCase() === "person"
560
+ ? `\nCRITICAL: The atomic side is "Person" meaning INDIVIDUAL HUMAN BEINGS ONLY.
546
561
  - Return ONLY specific individual people with proper names (e.g., "Leonardo da Vinci"), not categories, groups, or locations.
547
562
  - 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
563
  - DO NOT return locations, places, buildings, or geographical entities (e.g., do NOT return "Florence" or "Italy").
549
564
  - DO NOT return generic or collective phrases like "Various Local Artists", "Local Artists", "Staff", "Visitors", "Students", "Members", "Volunteers", "Team", "The Public", "Curators".
550
565
  - 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).
566
+ : "";
567
+ const workSourceHint =
568
+ (compositeType || "").trim().toLowerCase() === "event"
569
+ ? `\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
570
  - 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
- : "";
571
+ : "";
572
+ const theorySourceHint =
573
+ /\b(theory|concept|discovery|law|principle|formula|field|science|physics|mathematics|biology|chemistry|mechanics|evolution|relativity)\b/i.test(compositeType || "") ||
574
+ /\b(theory|physics|mathematics|discovery|principle|mechanics|evolution|relativity)\b/i.test(nodeName)
575
+ ? `\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.`
576
+ : "";
577
+
566
578
 
579
+ try {
567
580
  const prompt = `${contextualPrompt}${wikiPrompt}${mentionPrompt}${excludePrompt}
568
581
  Source Node: ${nodeName} (Type: ${compositeLabel})
569
582
 
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}.
583
+ 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
584
 
572
585
  Straying Guardrails:
573
586
  ${personOnlyRule}
574
587
  ${workSourceHint}
575
588
  ${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." : ""}
589
+ ${(compositeType || "").match(/^(Movie|Film|Book|Novel|Play|Opera)$/i) ? '\nSPECIAL CASE (Fiction): For works of fiction, prioritize returning CHARACTERS as the atomic entities.' : ''}
590
+ ${(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
591
 
592
+ 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.
593
+
579
594
  CRITICAL BIPARTITE RULE:
580
595
  - The Source Node is a COMPOSITE entity.
581
596
  - Therefore, ALL returned entities MUST be ATOMIC entities (${atomicLabel}).
582
597
  - DO NOT return other ${compositeLabel} entities.
583
598
  - If you find connections to other ${compositeLabel} entities, you MUST find the ${atomicLabel} entities (people, characters, etc.) that link them.
584
599
 
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)}.` : ""}
600
+ ${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
601
 
587
602
  IMPORTANT: For each entity specify its type (${atomicLabel}) and whether it follows the classification rules defined in the system instruction.
588
603
 
@@ -597,89 +612,56 @@ export const fetchConnections = async (
597
612
 
598
613
  // console.log(`🤖 [Gemini] fetchConnections Prompt for "${nodeName}":`, prompt);
599
614
 
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"],
615
+ const makeApiCall = () => ai.models.generateContent({
616
+ model: getGeminiModel(),
617
+ contents: prompt,
618
+ config: {
619
+ systemInstruction: SYSTEM_INSTRUCTION,
620
+ responseMimeType: "application/json",
621
+ responseSchema: {
622
+ type: Type.OBJECT,
623
+ properties: {
624
+ sourceYear: { type: Type.INTEGER, description: "Year of the source node" },
625
+ people: {
626
+ type: Type.ARRAY,
627
+ items: {
628
+ type: Type.OBJECT,
629
+ properties: {
630
+ name: { type: Type.STRING },
631
+ isAtomic: { type: Type.BOOLEAN, nullable: true, description: "True if atomic, false if composite" },
632
+ wikipediaTitle: { type: Type.STRING, nullable: true, description: "Canonical English Wikipedia article title for this entity (use disambiguation parentheses when needed)" },
633
+ role: { type: Type.STRING, nullable: true, description: "Role in the requested Source Node" },
634
+ description: { type: Type.STRING, nullable: true, description: "Short 1-sentence bio" },
635
+ evidenceSnippet: { type: Type.STRING, description: "1 sentence evidence; if VERIFIED INFORMATION is provided, prefer verbatim from it" },
636
+ evidencePageTitle: { type: Type.STRING, description: "Wikipedia page title where the snippet came from (usually the source)" }
667
637
  },
668
- },
669
- })
670
- : undefined,
638
+ required: ["name", "evidenceSnippet", "evidencePageTitle"]
639
+ }
640
+ }
641
+ },
642
+ required: ["people"]
643
+ }
644
+ }
671
645
  });
646
+
647
+ const response = await withRetry(
648
+ () => withTimeout(makeApiCall(), GEMINI_TIMEOUT_MS, "Gemini API request timed out"),
649
+ 4,
650
+ 1000
651
+ );
652
+
653
+ const rawText = getResponseText(response);
672
654
  // console.log(`🤖 [Gemini] Raw response for "${nodeName}":`, rawText);
673
- const text = cleanJson(rawText);
674
- if (!text) return { people: [] };
655
+ const parsed = parseJsonFromModelText(rawText) as GeminiResponse | null;
656
+ if (!parsed || !Array.isArray(parsed.people)) return { people: [] };
675
657
 
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
- }));
658
+ // Force correct bipartite type regardless of LLM slip-ups; drop junk entity names
659
+ parsed.people = parsed.people
660
+ .filter(p => isValidEntityName(p.name))
661
+ .map(p => ({
662
+ ...p,
663
+ isAtomic: true // In fetchConnections, the source is COMPOSITE, so all results MUST be ATOMIC (true)
664
+ }));
683
665
 
684
666
  return parsed;
685
667
  } catch (error) {
@@ -695,99 +677,47 @@ export const fetchPersonWorks = async (
695
677
  wikipediaId?: string,
696
678
  atomicType?: string,
697
679
  compositeType?: string,
698
- mentioningPageTitles?: string[],
680
+ mentioningPageTitles?: string[]
699
681
  ): Promise<PersonWorksResponse> => {
700
682
  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;
683
+ return callAiProxy("/api/ai/works", { nodeName, excludeNodes, wikiContext, wikipediaId, atomicType, compositeType, mentioningPageTitles });
748
684
  }
749
685
 
750
- try {
751
- const apiKey = await getLlmApiKey();
752
- if (!apiKey) {
753
- console.error("[Gemini] fetchPersonWorks: No API key found");
754
- return { works: [] };
686
+ const apiKey = await getApiKey();
687
+ if (!apiKey) {
688
+ if (process.env.NODE_ENV !== "production") {
689
+ console.warn("[Gemini] fetchPersonWorks: No API key — returning empty works");
755
690
  }
691
+ return { works: [] };
692
+ }
756
693
 
757
- const useGemini = getLlmProvider() === "gemini";
758
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
694
+ const ai = new GoogleGenAI({ apiKey });
759
695
 
760
- const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
761
- const wikiPrompt = wikiContext
762
- ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
763
- : "";
696
+ const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
697
+ const wikiPrompt = wikiContext
698
+ ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
699
+ : "";
764
700
 
765
- const atomicLabel = atomicType || "ATOMIC entity";
766
- const compositeLabel = compositeType || "COMPOSITE entity";
701
+ const atomicLabel = atomicType || "ATOMIC entity";
702
+ const compositeLabel = compositeType || "COMPOSITE entity";
767
703
 
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
- : "";
704
+ const mentionPrompt = mentioningPageTitles && mentioningPageTitles.length > 0
705
+ ? `\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.`
706
+ : "";
772
707
 
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");
708
+ const dateRequired = (compositeType || "").match(/^(Event|Paper|Work|Movie|Film|Book|Novel|Album|Song|Composition|Artwork|Painting|Sculpture)$/i) ||
709
+ (compositeLabel.toLowerCase().includes('event') || compositeLabel.toLowerCase().includes('work'));
779
710
 
780
- const dateRequirementPrompt = dateRequired
781
- ? `\nDATE REQUIREMENT:
711
+ const dateRequirementPrompt = dateRequired
712
+ ? `\nDATE REQUIREMENT:
782
713
  - Every ${compositeLabel} MUST have a valid year (creation, publication, start date, or occurrence).
783
714
  - If you do not know the year, DO NOT include the entity.`
784
- : "";
715
+ : "";
785
716
 
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.
717
+ const contextPrompt = excludeNodes.length > 0
718
+ ? `The user graph already contains these nodes connected to ${nodeName}${wikiIdStr}: ${JSON.stringify(excludeNodes)}.
719
+ Return 6-8 NEW significant ${compositeLabel} entities.`
720
+ : `List 5-6 DISTINCT, significant ${compositeLabel} entities that this ${atomicLabel} "${nodeName}"${wikiIdStr} belongs to or is part of.
791
721
 
792
722
  CRITICAL: A ${compositeLabel} must be a named organization, team, project, work, recipe, disease, location, or specific historical event/incident.
793
723
  DO NOT return descriptive phrases, facts, or achievements.
@@ -828,6 +758,11 @@ export const fetchPersonWorks = async (
828
758
  - Set the returned item's "type" to "Album" (or "Composition" / "Symphony" / "Song" when clearly applicable).
829
759
  - QUOTA: For a musician, return AT LEAST 6-8 specific major albums or compositions.
830
760
 
761
+ 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".
762
+ - DO NOT include the performer's name, orchestra name, label, or recording info in the composition title.
763
+ - DO NOT return YouTube channel names, concert series names, or recording platforms (e.g., "France Musique concerts1899", "DG Archive") as entities.
764
+ - This naming convention ensures the composer's name travels with the node and can be extracted when the node is later expanded.
765
+
831
766
  SPECIAL CASE (ingredient/food): If "${nodeName}" is an ingredient or food item, return 8-10 specific recipes that prominently feature this ingredient.
832
767
  - Set the returned item's "type" field to "Recipe".
833
768
  - 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 +787,6 @@ export const fetchPersonWorks = async (
852
787
  - For a Person involved in a recent event: Return the named Event or Incident (e.g. "Killing of Renee Good", "2026 Minneapolis Protests").
853
788
  - For an Ingredient (e.g. "Chicken"): Return specific Recipes.
854
789
  - 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
790
  - For an Artist: Return specific major Artworks (e.g., "Mona Lisa", "The Last Supper") and optionally a few key Exhibitions/Movements.
857
791
  - For a Mathematician: Return specific named Papers (often coauthored).
858
792
 
@@ -862,157 +796,66 @@ export const fetchPersonWorks = async (
862
796
  - DO NOT return other ${atomicLabel} entities (other people, actors, or characters).
863
797
  - 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
798
 
799
+ try {
865
800
  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."}`;
801
+ Ensure each entry is a different entity. ${dateRequired ? 'Sort by year. STRICTLY avoid entities without a known year.' : 'Sort by year if applicable.'}`;
867
802
 
868
803
  // console.log(`🤖 [Gemini] fetchPersonWorks Prompt for "${nodeName}":`, prompt);
869
804
 
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"],
805
+ const makeApiCall = () => ai.models.generateContent({
806
+ model: getGeminiModel(),
807
+ contents: prompt,
808
+ config: {
809
+ systemInstruction: SYSTEM_INSTRUCTION,
810
+ responseMimeType: "application/json",
811
+ responseSchema: {
812
+ type: Type.OBJECT,
813
+ properties: {
814
+ works: {
815
+ type: Type.ARRAY,
816
+ items: {
817
+ type: Type.OBJECT,
818
+ properties: {
819
+ entity: { type: Type.STRING },
820
+ isAtomic: { type: Type.BOOLEAN, nullable: true, description: "True if atomic, false if composite" },
821
+ wikipediaTitle: { type: Type.STRING, nullable: true, description: "Canonical English Wikipedia article title for this entity (use disambiguation parentheses when needed)" },
822
+ type: { type: Type.STRING },
823
+ description: { type: Type.STRING, nullable: true, description: "Short 1-sentence description" },
824
+ role: { type: Type.STRING, nullable: true },
825
+ year: { type: Type.INTEGER, nullable: true, description: "4-digit year (YYYY), required for events/works" },
826
+ evidenceSnippet: { type: Type.STRING, description: "1 sentence evidence; if VERIFIED INFORMATION is provided, prefer verbatim from it" },
827
+ evidencePageTitle: { type: Type.STRING, description: "Wikipedia page title where the snippet came from (usually the source)" }
937
828
  },
938
- },
939
- })
940
- : undefined,
829
+ required: ["entity", "type", "evidenceSnippet", "evidencePageTitle"]
830
+ }
831
+ }
832
+ },
833
+ required: ["works"]
834
+ }
835
+ }
941
836
  });
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
837
 
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
- });
838
+ const response = await withRetry(
839
+ () => withTimeout(makeApiCall(), GEMINI_TIMEOUT_MS, "Gemini API request timed out"),
840
+ 4,
841
+ 1000
842
+ );
999
843
 
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
- );
844
+ const rawText = getResponseText(response);
845
+ // console.log(`🤖 [Gemini] Raw response for "${nodeName}" (works):`, rawText);
846
+ const parsed = parseJsonFromModelText(rawText) as PersonWorksResponse | null;
847
+ if (!parsed || !Array.isArray(parsed.works)) return { works: [] };
848
+ // Force correct bipartite type regardless of LLM slip-ups; drop junk entity names
849
+ if (parsed.works) {
850
+ parsed.works = parsed.works.filter(w => isValidEntityName(w.entity));
851
+ if (dateRequired) {
852
+ parsed.works = parsed.works.filter(w => w.year !== null && w.year !== undefined && !isNaN(Number(w.year)));
1010
853
  }
854
+ parsed.works = parsed.works.map(w => ({
855
+ ...w,
856
+ isAtomic: false // In fetchPersonWorks, the source is ATOMIC, so all results MUST be COMPOSITE (false)
857
+ }));
1011
858
  }
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
859
  return parsed;
1017
860
  } catch (error) {
1018
861
  console.error("Gemini API Error (Person Works):", error);
@@ -1020,31 +863,26 @@ export const fetchPersonWorks = async (
1020
863
  }
1021
864
  };
1022
865
 
1023
- export const fetchConnectionPath = async (
1024
- start: string,
1025
- end: string,
1026
- context?: { startWiki?: string; endWiki?: string },
1027
- ): Promise<PathResponse> => {
866
+ export const fetchConnectionPath = async (start: string, end: string, context?: { startWiki?: string; endWiki?: string }): Promise<PathResponse> => {
1028
867
  if (shouldProxy()) {
1029
868
  return callAiProxy("/api/ai/path", { start, end, context });
1030
869
  }
1031
870
 
1032
- try {
1033
- const apiKey = await getLlmApiKey();
1034
- if (!apiKey) {
1035
- console.error("[Gemini] fetchConnectionPath: No API key found");
1036
- return { path: [], found: false };
871
+ const apiKey = await getApiKey();
872
+ if (!apiKey) {
873
+ if (process.env.NODE_ENV !== "production") {
874
+ console.warn("[Gemini] fetchConnectionPath: No API key — returning empty path");
1037
875
  }
876
+ return { path: [], found: false };
877
+ }
1038
878
 
1039
- const useGemini = getLlmProvider() === "gemini";
1040
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
879
+ const ai = new GoogleGenAI({ apiKey });
1041
880
 
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
- : "";
881
+ const wikiPrompt = (context?.startWiki || context?.endWiki)
882
+ ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${context?.startWiki ? `[${start}]: ${context.startWiki}\n` : ''}${context?.endWiki ? `[${end}]: ${context.endWiki}\n` : ''}`
883
+ : "";
1046
884
 
1047
- const prompt = `Find a connection path between "${start}" and "${end}".
885
+ const prompt = `Find a connection path between "${start}" and "${end}".
1048
886
  ${wikiPrompt}
1049
887
 
1050
888
  Your goal is to find the most direct and historically significant connection path.
@@ -1096,61 +934,44 @@ export const fetchConnectionPath = async (
1096
934
  ]
1097
935
  }`;
1098
936
 
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"],
937
+ try {
938
+ const response = await withTimeout(ai.models.generateContent({
939
+ model: getGeminiModel(),
940
+ contents: prompt,
941
+ config: {
942
+ systemInstruction: SYSTEM_INSTRUCTION,
943
+ responseMimeType: "application/json",
944
+ responseSchema: {
945
+ type: Type.OBJECT,
946
+ properties: {
947
+ path: {
948
+ type: Type.ARRAY,
949
+ items: {
950
+ type: Type.OBJECT,
951
+ properties: {
952
+ id: { type: Type.STRING },
953
+ type: { type: Type.STRING },
954
+ description: { type: Type.STRING },
955
+ justification: { type: Type.STRING, description: "Relationship to the PREVIOUS node in the chain" },
956
+ year: { type: Type.INTEGER, nullable: true, description: "Year of occurrence/creation (Required for Events)" }
1145
957
  },
1146
- },
1147
- })
1148
- : undefined,
1149
- });
1150
- const json = JSON.parse(cleanJson(text));
958
+ required: ["id", "type", "description", "justification"]
959
+ }
960
+ }
961
+ },
962
+ required: ["path"]
963
+ }
964
+ }
965
+ }), 45000, "Pathfinding timed out");
966
+
967
+ const text = getResponseText(response);
968
+ const json = parseJsonFromModelText(text) as { path?: PathResponse["path"] } | null;
969
+ if (!json || !Array.isArray(json.path)) {
970
+ return { path: [], found: false };
971
+ }
1151
972
 
1152
973
  // Ensure the path starts with the start node and ends with the end node
1153
- if (json.path && json.path.length > 0) {
974
+ if (json.path.length > 0) {
1154
975
  const first = json.path[0].id.toLowerCase();
1155
976
  const last = json.path[json.path.length - 1].id.toLowerCase();
1156
977
  const startLow = start.toLowerCase();
@@ -1162,8 +983,7 @@ export const fetchConnectionPath = async (
1162
983
  id: start,
1163
984
  type: "Start",
1164
985
  description: context?.startWiki?.substring(0, 100) || "Start node",
1165
- justification: "Start of path",
1166
- year: null,
986
+ justification: "Start of path"
1167
987
  });
1168
988
  }
1169
989
  if (!last.includes(endLow) && !endLow.includes(last)) {
@@ -1171,33 +991,28 @@ export const fetchConnectionPath = async (
1171
991
  id: end,
1172
992
  type: "End",
1173
993
  description: context?.endWiki?.substring(0, 100) || "End node",
1174
- justification: "Destination",
1175
- year: null,
994
+ justification: "Destination"
1176
995
  });
1177
996
  }
1178
997
  }
1179
998
 
1180
- return json as PathResponse;
999
+ return { path: json.path, found: json.path.length > 0 };
1181
1000
  } catch (error) {
1182
1001
  console.error("Gemini Pathfinding Error:", error);
1183
1002
  return { path: [], found: false };
1184
1003
  }
1185
1004
  };
1186
1005
 
1187
- export const findWikipediaTitle = async (
1188
- name: string,
1189
- description?: string,
1190
- ): Promise<{ title: string; imageHint?: string } | null> => {
1006
+ export const findWikipediaTitle = async (name: string, description?: string): Promise<{ title: string; imageHint?: string } | null> => {
1191
1007
  if (shouldProxy()) {
1192
1008
  return callAiProxy("/api/ai/title", { name, description });
1193
1009
  }
1194
1010
 
1195
- const apiKey = await getLlmApiKey();
1011
+ const apiKey = await getApiKey();
1196
1012
  if (!apiKey) return null;
1197
- const useGemini = getLlmProvider() === "gemini";
1198
- const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
1013
+ const ai = new GoogleGenAI({ apiKey });
1199
1014
 
1200
- const prompt = `Find the exact English Wikipedia article title for "${name}"${description ? ` described as "${description}"` : ""}.
1015
+ const prompt = `Find the exact English Wikipedia article title for "${name}"${description ? ` described as "${description}"` : ''}.
1201
1016
  Also, if you know a specific Wikimedia Commons filename for a good portrait of this person/thing, include it.
1202
1017
 
1203
1018
  Return JSON:
@@ -1207,33 +1022,28 @@ export const findWikipediaTitle = async (
1207
1022
  }`;
1208
1023
 
1209
1024
  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));
1025
+ const response = await withTimeout(ai.models.generateContent({
1026
+ model: getGeminiModel(),
1027
+ contents: prompt,
1028
+ config: {
1029
+ responseMimeType: "application/json",
1030
+ responseSchema: {
1031
+ type: Type.OBJECT,
1032
+ properties: {
1033
+ title: { type: Type.STRING },
1034
+ imageHint: { type: Type.STRING, nullable: true }
1035
+ },
1036
+ required: ["title"]
1037
+ }
1038
+ }
1039
+ }), 10000, "Title lookup timed out");
1040
+
1041
+ const text = getResponseText(response);
1042
+ const json = parseJsonFromModelText(text) as { title?: string; imageHint?: string } | null;
1043
+ if (!json || typeof json.title !== "string" || !json.title.trim()) return null;
1234
1044
  return {
1235
1045
  title: json.title,
1236
- imageHint: json.imageHint,
1046
+ imageHint: json.imageHint
1237
1047
  };
1238
1048
  } catch (e) {
1239
1049
  // console.warn("AI title lookup failed", e);
@@ -1243,17 +1053,12 @@ export const findWikipediaTitle = async (
1243
1053
 
1244
1054
  // Optional: grounded lookup for org leadership using Google Search tool.
1245
1055
  // NOTE: This cannot use responseSchema/responseMimeType; we parse JSON from text.
1246
- export const fetchOrgKeyPeopleBlockViaSearch = async (
1247
- orgName: string,
1248
- ): Promise<string | null> => {
1056
+ export const fetchOrgKeyPeopleBlockViaSearch = async (orgName: string): Promise<string | null> => {
1249
1057
  if (shouldProxy()) {
1250
1058
  return callAiProxy("/api/ai/search-org", { orgName });
1251
1059
  }
1252
1060
 
1253
- // Google Search grounding is Gemini-only in this codebase.
1254
- if (getLlmProvider() !== "gemini") return null;
1255
-
1256
- const apiKey = await getLlmApiKey();
1061
+ const apiKey = await getApiKey();
1257
1062
  if (!apiKey) return null;
1258
1063
 
1259
1064
  const name = String(orgName || "").trim();
@@ -1284,21 +1089,19 @@ Rules:
1284
1089
  model: getGeminiModel(),
1285
1090
  contents: prompt,
1286
1091
  config: {
1287
- systemInstruction:
1288
- "You are a careful research assistant. Use Google Search for grounding and do not invent facts.",
1289
- tools: [{ googleSearch: {} }],
1290
- },
1092
+ systemInstruction: "You are a careful research assistant. Use Google Search for grounding and do not invent facts.",
1093
+ tools: [{ googleSearch: {} }]
1094
+ }
1291
1095
  }),
1292
1096
  20000,
1293
- "Org key-people search timed out",
1097
+ "Org key-people search timed out"
1294
1098
  ),
1295
1099
  4,
1296
- 1000,
1100
+ 1000
1297
1101
  );
1298
1102
 
1299
- const text = cleanJson(getResponseText(response));
1300
- if (!text) return null;
1301
- const json = JSON.parse(text) as any;
1103
+ const json = parseJsonFromModelText(getResponseText(response)) as { founders?: unknown; keyPeople?: unknown } | null;
1104
+ if (!json || typeof json !== "object" || Array.isArray(json)) return null;
1302
1105
  const founders = Array.isArray(json?.founders) ? json.founders : [];
1303
1106
  const keyPeople = Array.isArray(json?.keyPeople) ? json.keyPeople : [];
1304
1107
 
@@ -1309,7 +1112,7 @@ Rules:
1309
1112
  name: String(x.name).trim(),
1310
1113
  evidence: x?.evidence ? String(x.evidence).trim() : "",
1311
1114
  sourceTitle: x?.sourceTitle ? String(x.sourceTitle).trim() : "",
1312
- sourceUrl: x?.sourceUrl ? String(x.sourceUrl).trim() : "",
1115
+ sourceUrl: x?.sourceUrl ? String(x.sourceUrl).trim() : ""
1313
1116
  }))
1314
1117
  .filter((x: any) => x.name);
1315
1118
 
@@ -1321,7 +1124,7 @@ Rules:
1321
1124
  role: x?.role ? String(x.role).trim() : "",
1322
1125
  evidence: x?.evidence ? String(x.evidence).trim() : "",
1323
1126
  sourceTitle: x?.sourceTitle ? String(x.sourceTitle).trim() : "",
1324
- sourceUrl: x?.sourceUrl ? String(x.sourceUrl).trim() : "",
1127
+ sourceUrl: x?.sourceUrl ? String(x.sourceUrl).trim() : ""
1325
1128
  }))
1326
1129
  .filter((x: any) => x.name);
1327
1130
 
@@ -1329,28 +1132,24 @@ Rules:
1329
1132
 
1330
1133
  const lines: string[] = [];
1331
1134
  if (f.length) {
1332
- lines.push(`Founders: ${f.map((x) => x.name).join(", ")}`);
1135
+ lines.push(`Founders: ${f.map((x: { name: string }) => x.name).join(", ")}`);
1333
1136
  }
1334
1137
  if (kp.length) {
1335
1138
  lines.push(
1336
1139
  `Key People: ${kp
1337
- .map((x) => (x.role ? `${x.name} (${x.role})` : x.name))
1338
- .join(", ")}`,
1140
+ .map((x: { name: string; role: string }) => (x.role ? `${x.name} (${x.role})` : x.name))
1141
+ .join(", ")}`
1339
1142
  );
1340
1143
  }
1341
1144
  const sources = [...f, ...kp]
1342
- .map((x) =>
1343
- x.sourceUrl ? `${x.sourceTitle || "Source"} — ${x.sourceUrl}` : "",
1344
- )
1145
+ .map((x: { sourceUrl?: string; sourceTitle?: string }) => (x.sourceUrl ? `${x.sourceTitle || "Source"} — ${x.sourceUrl}` : ""))
1345
1146
  .filter(Boolean);
1346
1147
  const uniqueSources = Array.from(new Set(sources)).slice(0, 8);
1347
1148
 
1348
1149
  return [
1349
1150
  `GOOGLE_SEARCH_GROUNDED (for "${name}")`,
1350
- ...lines.map((l) => `- ${l}`),
1351
- ...(uniqueSources.length
1352
- ? ["Sources:", ...uniqueSources.map((s) => `- ${s}`)]
1353
- : []),
1151
+ ...lines.map(l => `- ${l}`),
1152
+ ...(uniqueSources.length ? ["Sources:", ...uniqueSources.map(s => `- ${s}`)] : [])
1354
1153
  ].join("\n");
1355
1154
  } catch (e) {
1356
1155
  // console.warn("Org key-people search failed:", name, e);