@johndimm/constellations 1.0.0

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 (44) hide show
  1. package/App.tsx +480 -0
  2. package/FullPageConstellations.tsx +74 -0
  3. package/FullPageConstellationsHostShell.tsx +27 -0
  4. package/README.md +116 -0
  5. package/components/AppConfirmDialog.tsx +46 -0
  6. package/components/AppHeader.tsx +73 -0
  7. package/components/AppNotifications.tsx +21 -0
  8. package/components/BrowsePeople.tsx +832 -0
  9. package/components/ControlPanel.tsx +1023 -0
  10. package/components/Graph.tsx +1525 -0
  11. package/components/HelpOverlay.tsx +168 -0
  12. package/components/NodeContextMenu.tsx +160 -0
  13. package/components/PeopleBrowserSidebar.tsx +690 -0
  14. package/components/Sidebar.tsx +271 -0
  15. package/components/TimelineView.tsx +4 -0
  16. package/hooks/useExpansion.ts +889 -0
  17. package/hooks/useGraphActions.ts +325 -0
  18. package/hooks/useGraphState.ts +414 -0
  19. package/hooks/useKioskMode.ts +47 -0
  20. package/hooks/useNodeClickHandler.ts +172 -0
  21. package/hooks/useSearchHandlers.ts +369 -0
  22. package/host.ts +16 -0
  23. package/index.css +101 -0
  24. package/index.tsx +16 -0
  25. package/kioskDomains.ts +307 -0
  26. package/package.json +78 -0
  27. package/services/aiUtils.ts +364 -0
  28. package/services/cacheService.ts +76 -0
  29. package/services/crossrefService.ts +107 -0
  30. package/services/geminiService.ts +1359 -0
  31. package/services/get-local-graphs.js +5 -0
  32. package/services/graphUtils.ts +347 -0
  33. package/services/imageService.ts +39 -0
  34. package/services/llmClient.ts +194 -0
  35. package/services/openAlexService.ts +173 -0
  36. package/services/wikipediaImage.ts +40 -0
  37. package/services/wikipediaService.ts +1175 -0
  38. package/sessionHandoff.ts +132 -0
  39. package/types.ts +99 -0
  40. package/useFullPageConstellationsHost.ts +116 -0
  41. package/utils/evidenceUtils.ts +107 -0
  42. package/utils/graphLogicUtils.ts +32 -0
  43. package/utils/graphNodeToChannelNotes.ts +71 -0
  44. package/utils/wikiUtils.ts +34 -0
@@ -0,0 +1,1359 @@
1
+ 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
+ }
25
+
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";
42
+
43
+ const SYSTEM_INSTRUCTION = `
44
+ You are a Bipartite Graph Generator.
45
+ Your goal is to build a graph that alternates between an "Atomic" type and a "Composite" type.
46
+
47
+ BIPARTITE STRUCTURE:
48
+ A bipartite graph alternates between an "Atomic" entity type and a "Composite" entity type.
49
+ - Atomic: Fundamental building blocks (e.g., individual people, ingredients, symptoms, authors, actors, components)
50
+ - Composite: Collections or works (e.g., events, recipes, diseases, papers, movies, products, organizations)
51
+
52
+ Common bipartite pairs include:
53
+ - Person ↔ Event (works, historical events, organizations, movements)
54
+ - Ingredient ↔ Recipe
55
+ - Symptom ↔ Disease
56
+ - Author ↔ Paper
57
+ - Actor ↔ Movie
58
+ - Component ↔ Product
59
+ - Character ↔ Novel
60
+
61
+ CRITICAL EXAMPLES TO PREVENT MISCLASSIFICATION:
62
+ - "The Godfather" → COMPOSITE (type: Movie, isAtomic: false), pair: Actor ↔ Movie
63
+ - "Marlon Brando" → ATOMIC (type: Actor, isAtomic: true), pair: Actor ↔ Movie
64
+ - "Star Wars" → COMPOSITE (type: Movie, isAtomic: false), pair: Actor ↔ Movie
65
+ - Movies/books/albums are ALWAYS composite (created BY actors/authors/musicians)
66
+
67
+ CRITICAL ACCURACY RULE:
68
+ If a section titled "USE THIS VERIFIED INFORMATION FOR ACCURACY" is provided, you MUST prioritize this information above your own internal knowledge.
69
+
70
+ Core Rules:
71
+ 1. If the Source is a Composite, return 8-10 distinct Atomics that are meaningfully connected to it.
72
+ 2. If the Source is an Atomic, return 8-10 distinct Composites that it is meaningfully connected to.
73
+ 3. Use Title Case for all names.
74
+ 4. Return only factually correct information. Do not hallucinate.
75
+
76
+ Output Format Rules (apply to ALL responses):
77
+ - wikipediaTitle: Always provide the canonical English Wikipedia article title (use parenthetical disambiguation when needed, e.g. "Euphoria (TV series)", "Prince (musician)", "The Godfather").
78
+ - evidenceSnippet: Provide a 1-sentence evidence snippet explaining the connection.
79
+ * If VERIFIED INFORMATION is provided, the evidence snippet MUST be copied verbatim from that text and should contain BOTH entity names when possible.
80
+ * If no good verbatim quote exists, provide a brief explicit rationale (no quotes).
81
+ - evidencePageTitle: Set to the Wikipedia article title the snippet is from (usually the source).
82
+
83
+ Entity Classification:
84
+ - isAtomic: true for INDIVIDUAL PEOPLE/CHARACTERS (atomic), false for WORKS/GROUPS/ORGANIZATIONS (composite).
85
+ * Atomic entities (Actor, Person, Author, Artist, Character, Scientist, Philosopher, Academic, Researcher, Director, Composer) → isAtomic=true
86
+ * Composite entities (Movie, Book, Novel, Play, Album, Band, Organization, Institution, Movement, Event, Company, Paper, Theory, Paradox) → isAtomic=false
87
+
88
+ Return strict JSON.
89
+ `;
90
+
91
+ // Loosened timeouts to tolerate slower responses without failing immediately.
92
+ const GEMINI_TIMEOUT_MS = 60000; // 60 seconds for heavier graph expansions
93
+ 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
+
97
+ // Model selection (configurable via Vite env vars)
98
+ // - VITE_GEMINI_MODEL: used for expansions + pathfinding (default)
99
+ // - VITE_GEMINI_MODEL_CLASSIFY: optional override for classification
100
+ const getGeminiModel = getEnvGeminiModel;
101
+ const getGeminiModelClassify = getEnvGeminiModelClassify;
102
+
103
+ export type LockedPair = {
104
+ atomicType: string;
105
+ compositeType: string;
106
+ };
107
+
108
+ // --- Proxy Helper ---
109
+ async function callAiProxy(endpoint: string, body: any) {
110
+ const baseUrl = getEnvCacheUrl();
111
+ let resolvedBase = baseUrl;
112
+
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 : {});
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
+ );
131
+
132
+ if (resp.status === 404 && endpoint === "/api/ai/classify-start") {
133
+ // console.warn(`⚠️ [Proxy] ${endpoint} not found, falling back to /api/ai/classify`);
134
+ return callAiProxy("/api/ai/classify", payload);
135
+ }
136
+
137
+ if (!resp.ok) {
138
+ 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
+ throw new Error(`AI Proxy Error (${resp.status}): ${err}`);
183
+ }
184
+ const data = await resp.json();
185
+ console.info("[LLM] proxy RESPONSE", endpoint, clipForLlmLog(JSON.stringify(data)));
186
+ return data;
187
+ } 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
+ ) {
203
+ // Network error or fetch failure, try fallback anyway if it's the start pair
204
+ // console.warn(`⚠️ [Proxy] ${endpoint} failed, trying fallback /api/ai/classify`, e);
205
+ return callAiProxy("/api/ai/classify", payload);
206
+ }
207
+ throw e;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Helper to determine if we should use the proxy (browser + proxy URL available).
213
+ */
214
+ function shouldProxy(): boolean {
215
+ if (typeof window === "undefined") return false;
216
+ if ((window as any).__PRERENDER_INJECTED) return false;
217
+
218
+ const baseUrl = getEnvCacheUrl();
219
+ return !!baseUrl;
220
+ }
221
+
222
+ export function defaultStartPairResult(reason: string): {
223
+ type: string;
224
+ description: string;
225
+ isAtomic: boolean;
226
+ atomicType: string;
227
+ compositeType: string;
228
+ reasoning: string;
229
+ } {
230
+ return {
231
+ type: "Event",
232
+ description: "",
233
+ isAtomic: false,
234
+ atomicType: "Person",
235
+ compositeType: "Event",
236
+ reasoning: reason,
237
+ };
238
+ }
239
+
240
+ export const classifyStartPair = async (
241
+ term: string,
242
+ wikiContext?: string,
243
+ ): Promise<{
244
+ type: string;
245
+ description: string;
246
+ isAtomic: boolean;
247
+ atomicType: string;
248
+ compositeType: string;
249
+ reasoning: string;
250
+ }> => {
251
+ if (shouldProxy()) {
252
+ return callAiProxy("/api/ai/classify-start", { term, wikiContext });
253
+ }
254
+
255
+ const apiKey = await getLlmApiKey();
256
+ // String-level safety heuristic (no Wikipedia required):
257
+ // Disambiguated titles like "Discover (Daft Punk album)" must never be treated as Person.
258
+ // Treat common work/media parentheticals as Composite/Event in the temporary Person↔Event model.
259
+ const t = term.trim();
260
+ // Academic heuristics (no model required):
261
+ // 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
+ ) {
266
+ return {
267
+ type: "Paper",
268
+ description: "",
269
+ isAtomic: false,
270
+ atomicType: "Author",
271
+ compositeType: "Paper",
272
+ reasoning:
273
+ "Seed looks like an academic paper identifier (DOI/arXiv); selecting Author↔Paper.",
274
+ };
275
+ }
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
+ ) {
281
+ return {
282
+ type: "Event",
283
+ description: "",
284
+ isAtomic: false,
285
+ atomicType: "Person",
286
+ compositeType: "Event",
287
+ reasoning:
288
+ "Title contains an explicit work/media disambiguator (e.g., '(album)'); treating it as Composite in Person↔Event.",
289
+ };
290
+ }
291
+
292
+ if (!apiKey) {
293
+ return defaultStartPairResult(
294
+ "No API key available; defaulting to Person↔Event.",
295
+ );
296
+ }
297
+
298
+ const useGemini = getLlmProvider() === "gemini";
299
+ const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
300
+
301
+ const prompt = `Choose the most appropriate bipartite pair for this session based on the input: "${term}".
302
+
303
+ You may identify other valid bipartite structures if appropriate for "${term}".
304
+
305
+ Rules:
306
+ - If "${term}" is an individual human (one person, one actor), it is ATOMIC (type: Person or Actor).
307
+ - If "${term}" is a WORK (movie, album, book, film, TV show, painting, song), it is ALWAYS COMPOSITE (type: Movie, Book, Album, etc.).
308
+ - If "${term}" is an organization/institution/band, it is ALWAYS COMPOSITE.
309
+ - If "${term}" looks like an academic paper or DOI/arXiv, it is COMPOSITE (use Author ↔ Paper).
310
+ - If "${term}" is a very famous person, it is ATOMIC even if they have works.
311
+ `;
312
+
313
+ 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,
340
+ });
341
+ // console.log(`🤖 [Gemini] Raw Classify-Start response for "${term}":`, rawText);
342
+ const text = cleanJson(rawText);
343
+ const json = text ? JSON.parse(text) : {};
344
+
345
+ return {
346
+ type: json.type || "Event",
347
+ description: json.description || "",
348
+ isAtomic: !!json.isAtomic,
349
+ atomicType: json.atomicType || "Person",
350
+ compositeType: json.compositeType || "Event",
351
+ reasoning: json.reasoning || "",
352
+ };
353
+ } catch (e: any) {
354
+ const msg = String(e?.message || e || "");
355
+ console.warn(
356
+ `[classifyStartPair] failed for "${term}":`,
357
+ msg.slice(0, 200),
358
+ );
359
+ return defaultStartPairResult(
360
+ "Classification API unavailable (quota, rate limit, or error); defaulting to Person↔Event.",
361
+ );
362
+ }
363
+ };
364
+
365
+ export const classifyEntity = async (
366
+ term: string,
367
+ wikiContext?: string,
368
+ ): Promise<{
369
+ type: string;
370
+ description: string;
371
+ isAtomic: boolean;
372
+ atomicType?: string;
373
+ compositeType?: string;
374
+ reasoning?: string;
375
+ }> => {
376
+ if (shouldProxy()) {
377
+ return callAiProxy("/api/ai/classify", { term, wikiContext });
378
+ }
379
+
380
+ const apiKey = await getLlmApiKey();
381
+ const normalized = term.trim().toLowerCase();
382
+
383
+ // String-level safety heuristic (no Wikipedia required):
384
+ // 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
+ ) {
390
+ return {
391
+ type: "Event",
392
+ description: "",
393
+ isAtomic: false,
394
+ atomicType: "Person",
395
+ compositeType: "Event",
396
+ reasoning:
397
+ "Title contains an explicit work/media disambiguator (e.g., '(album)'); treating it as Composite in Person↔Event.",
398
+ };
399
+ }
400
+
401
+ if (!apiKey) {
402
+ console.error("❌ [Gemini] classifyEntity: No API key found");
403
+ return { type: "Event", description: "", isAtomic: false };
404
+ }
405
+ const useGemini = getLlmProvider() === "gemini";
406
+ const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
407
+
408
+ const wikiPrompt = wikiContext
409
+ ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
410
+ : "";
411
+
412
+ try {
413
+ const prompt = `Classify "${term}". ${wikiPrompt}
414
+ Determine if it is "Atomic" (a fundamental building block like an individual human person, ingredient, or symptom)
415
+ or "Composite" (a collection/group/institution/work/event like a movie, recipe, disease, organization, or historical incident).
416
+
417
+ IMPORTANT:
418
+ - "Person" means an individual human only.
419
+ - Organizations, institutions, committees, societies, companies, and museums are NOT persons.
420
+ - Philosophers, Scientists, and Academics are INDIVIDUAL PEOPLE and should be ATOMIC (isAtomic: true).
421
+ - In the Person↔Event pairing, treat organizations as "Event" (Composite), NOT "Person".
422
+ - In the Person↔Event pairing, treat named works (albums, songs, books, novels, films, paintings, artworks) as "Event" (Composite), NOT "Person".
423
+ - In the Person↔Event pairing, treat major scientific theories, concepts, discoveries, paradoxes, or areas of study (e.g., "General Relativity", "Evolution", "Quantum Mechanics", "Russell's Paradox") as "Event" (Composite), NOT "Person".
424
+ - If the title explicitly contains a disambiguator like "(album)" / "(film)" / "(book)", it is a work: treat it as "Event" (Composite).
425
+
426
+ Identify the relevant Bipartite Pair this belongs to (e.g. Actor/Movie, Ingredient/Recipe, Symptom/Disease, Person/Event).
427
+
428
+ Return JSON:
429
+ {
430
+ "type": "Specific Type (one of: Person, Event, Ingredient, Recipe, Symptom, Disease, Author, Paper)",
431
+ "description": "Short 1-sentence description",
432
+ "isAtomic": true/false,
433
+ "atomicType": "The atomic labels (Person, Ingredient, Symptom, or Author)",
434
+ "compositeType": "The composite labels (Event, Recipe, Disease, or Paper)",
435
+ "reasoning": "Brief explanation"
436
+ }`;
437
+
438
+ // console.log("🤖 [Gemini] Classify Prompt:", prompt);
439
+
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,
469
+ });
470
+ // console.log(`🤖 [Gemini] Raw Classify response for "${term}":`, rawText);
471
+ const text = cleanJson(rawText);
472
+ // console.log("Classify response text:", text);
473
+ if (!text) return { type: "Event", description: "", isAtomic: false };
474
+ const json = JSON.parse(text);
475
+ 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,
482
+ };
483
+ } catch (error) {
484
+ // console.warn("Classification failed, defaulting to Event:", error);
485
+ return { type: "Event", description: "", isAtomic: false };
486
+ }
487
+ };
488
+
489
+ export const fetchConnections = async (
490
+ nodeName: string,
491
+ context?: string,
492
+ excludeNodes: string[] = [],
493
+ wikiContext?: string,
494
+ wikipediaId?: string,
495
+ atomicType?: string,
496
+ compositeType?: string,
497
+ mentioningPageTitles?: string[],
498
+ ): Promise<GeminiResponse> => {
499
+ 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
+ });
510
+ }
511
+
512
+ try {
513
+ const apiKey = await getLlmApiKey();
514
+ if (!apiKey) {
515
+ console.error("❌ [Gemini] fetchConnections: No API key found");
516
+ return { people: [] };
517
+ }
518
+
519
+ const useGemini = getLlmProvider() === "gemini";
520
+ const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
521
+
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}.`;
526
+
527
+ const wikiPrompt = wikiContext
528
+ ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
529
+ : "";
530
+
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.
546
+ - Return ONLY specific individual people with proper names (e.g., "Leonardo da Vinci"), not categories, groups, or locations.
547
+ - 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
+ - DO NOT return locations, places, buildings, or geographical entities (e.g., do NOT return "Florence" or "Italy").
549
+ - DO NOT return generic or collective phrases like "Various Local Artists", "Local Artists", "Staff", "Visitors", "Students", "Members", "Volunteers", "Team", "The Public", "Curators".
550
+ - 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).
555
+ - 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
+ : "";
566
+
567
+ const prompt = `${contextualPrompt}${wikiPrompt}${mentionPrompt}${excludePrompt}
568
+ Source Node: ${nodeName} (Type: ${compositeLabel})
569
+
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}.
571
+
572
+ Straying Guardrails:
573
+ ${personOnlyRule}
574
+ ${workSourceHint}
575
+ ${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." : ""}
578
+
579
+ CRITICAL BIPARTITE RULE:
580
+ - The Source Node is a COMPOSITE entity.
581
+ - Therefore, ALL returned entities MUST be ATOMIC entities (${atomicLabel}).
582
+ - DO NOT return other ${compositeLabel} entities.
583
+ - If you find connections to other ${compositeLabel} entities, you MUST find the ${atomicLabel} entities (people, characters, etc.) that link them.
584
+
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)}.` : ""}
586
+
587
+ IMPORTANT: For each entity specify its type (${atomicLabel}) and whether it follows the classification rules defined in the system instruction.
588
+
589
+ Examples:
590
+ - If Fiction (Book, Novel, Movie, Play): Return its most famous CHARACTERS.
591
+ - If Magazine/Newspaper: Return its most legendary WRITERS and EDITORS.
592
+ - If Theory/Discovery: Return the primary scientists or researchers involved.
593
+ - If Event/Incident: Return key people involved.
594
+ - If Team: Return key players.
595
+ - If Recipe: Return ingredients.
596
+ - If Disease: Return symptoms.`;
597
+
598
+ // console.log(`🤖 [Gemini] fetchConnections Prompt for "${nodeName}":`, prompt);
599
+
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"],
667
+ },
668
+ },
669
+ })
670
+ : undefined,
671
+ });
672
+ // console.log(`🤖 [Gemini] Raw response for "${nodeName}":`, rawText);
673
+ const text = cleanJson(rawText);
674
+ if (!text) return { people: [] };
675
+
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
+ }));
683
+
684
+ return parsed;
685
+ } catch (error) {
686
+ console.error("Gemini API Error (connections):", error);
687
+ return { people: [] };
688
+ }
689
+ };
690
+
691
+ export const fetchPersonWorks = async (
692
+ nodeName: string,
693
+ excludeNodes: string[] = [],
694
+ wikiContext?: string,
695
+ wikipediaId?: string,
696
+ atomicType?: string,
697
+ compositeType?: string,
698
+ mentioningPageTitles?: string[],
699
+ ): Promise<PersonWorksResponse> => {
700
+ 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;
748
+ }
749
+
750
+ try {
751
+ const apiKey = await getLlmApiKey();
752
+ if (!apiKey) {
753
+ console.error("❌ [Gemini] fetchPersonWorks: No API key found");
754
+ return { works: [] };
755
+ }
756
+
757
+ const useGemini = getLlmProvider() === "gemini";
758
+ const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
759
+
760
+ const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
761
+ const wikiPrompt = wikiContext
762
+ ? `\n\nUSE THIS VERIFIED INFORMATION FOR ACCURACY:\n${wikiContext}\n`
763
+ : "";
764
+
765
+ const atomicLabel = atomicType || "ATOMIC entity";
766
+ const compositeLabel = compositeType || "COMPOSITE entity";
767
+
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
+ : "";
772
+
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");
779
+
780
+ const dateRequirementPrompt = dateRequired
781
+ ? `\nDATE REQUIREMENT:
782
+ - Every ${compositeLabel} MUST have a valid year (creation, publication, start date, or occurrence).
783
+ - If you do not know the year, DO NOT include the entity.`
784
+ : "";
785
+
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.
791
+
792
+ CRITICAL: A ${compositeLabel} must be a named organization, team, project, work, recipe, disease, location, or specific historical event/incident.
793
+ DO NOT return descriptive phrases, facts, or achievements.
794
+ In the Person↔Event pair, treat locations (like "Saint-Paul-de-Mausole") as ${compositeLabel} entities.
795
+ ${dateRequirementPrompt}
796
+
797
+ BIDIRECTIONAL RULE:
798
+ - If "${nodeName}" is an author, you should prioritize including their most famous books/novels/works (unless already in the excluded list).
799
+ - If "${nodeName}" is a book, novel, movie, or play, you should prioritize including its most famous CHARACTERS (unless already in the excluded list).
800
+ - If "${nodeName}" is an artist, you should prioritize including their most famous paintings/sculptures/artworks (unless already in the excluded list).
801
+ - If "${nodeName}" is a writer famous for writing in a specific MAGAZINE (e.g., The New Yorker), you should prioritize including that Magazine (unless already in the excluded list).
802
+ - Ensure that if a user expands a creator, they find their works, and vice-versa.
803
+
804
+ BUSINESSPERSON GUARDRAIL:
805
+ - If "${nodeName}" appears to be an entrepreneur/business executive/investor, return ONLY organizations/companies/projects where they had a DIRECT ROLE (founder/co-founder/CEO/executive/chairman/partner/board member).
806
+ - DO NOT return generic "companies acquired by X" lists unless "${nodeName}" personally founded/led the acquired company or was a named executive involved.
807
+ - Prefer fewer, higher-confidence entities over a long list of weakly-related acquisitions.
808
+
809
+ FAMOUS MEETING RULE:
810
+ - Prioritize cases where two famous people finally meet each other in person or by some other direct one-on-one connection.
811
+ - PREFER: Specific events (summits, premieres, dinners, lab meetings) over broad eras or movements.
812
+ - AVOID: Broad eras (e.g., "World War II", "Civil Rights Movement") or shared workplaces (e.g., "Bell Labs", "Hollywood") unless no specific meeting or project exists.
813
+ - Illustrative Example: Alan Turing -> Meeting at Bell Labs (1943) -> Claude Shannon (BETTER than Turing -> World War II -> Shannon).
814
+ - Illustrative Example: Goethe -> Meeting at Teplitz -> Beethoven (BETTER than Goethe -> Romanticism -> Beethoven).
815
+
816
+ SPECIAL CASE (art): If "${nodeName}" is an artist (painter/sculptor/architect/photographer), include their major named artworks as returned entities.
817
+ - These artworks may be primarily made by a single person; that is OK.
818
+ - Set the returned item's "type" field to "Artwork" (or "Architecture" / "Sculpture" / "Painting" when clearly applicable).
819
+ - ALSO include a few multi-person art-world composites when applicable (e.g., key exhibitions featuring the artist, major movements the artist is associated with, or well-known patronage/collector contexts) to avoid dead-end single-person works.
820
+ - If you include those, set their type to "Event" or "Exhibition" or "Movement" as appropriate.
821
+ - QUOTA: For an artist, return AT LEAST 6 specific named works by the artist (paintings, sculptures, buildings, photo series).
822
+ - Movements/periods/styles (e.g., "Impressionism", "Modernism") must be at most 1 item total, and only if you also returned >=6 works.
823
+ - Do NOT return only movements/periods/styles; the primary goal is to list the artist's works.
824
+ - Prefer the artist's works over generic groupings. For painters, return paintings/series by name (e.g., "Water Lilies", "Impression, Sunrise", "Haystacks", "Rouen Cathedral series").
825
+
826
+ SPECIAL CASE (music): If "${nodeName}" is a musician (instrumentalist/composer/songwriter), include major named albums/compositions.
827
+ - Albums and major compositions are valid ${compositeLabel} in this system.
828
+ - Set the returned item's "type" to "Album" (or "Composition" / "Symphony" / "Song" when clearly applicable).
829
+ - QUOTA: For a musician, return AT LEAST 6-8 specific major albums or compositions.
830
+
831
+ SPECIAL CASE (ingredient/food): If "${nodeName}" is an ingredient or food item, return 8-10 specific recipes that prominently feature this ingredient.
832
+ - Set the returned item's "type" field to "Recipe".
833
+ - 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").
834
+ - Ensure variety in cuisines and preparation styles.
835
+ - Do NOT return generic terms like "beef dishes" - return specific, named recipes.
836
+
837
+ SPECIAL CASE (academia/math): If "${nodeName}" is a mathematician/scientist/researcher, include major named papers (often coauthored).
838
+ - Papers are valid ${compositeLabel} in this system.
839
+ - Prefer coauthored papers when possible (they connect to multiple people).
840
+ - Set the returned item's "type" to "Paper" when returning papers.
841
+
842
+ DIAMBIGUATION WARNING: Many entities share titles with famous songs, movies, or TV shows.
843
+ STRICTLY avoid pop-culture hallucinations.
844
+ Example: If a professional architect is mentioned in a book/interview called "Still Standing", DO NOT return the Elton John song "I'm Still Standing" unless the architect actually wrote/performed it.
845
+ Only return connections that are professionally or historically relevant to the specific individual described in the VERIFIED INFORMATION.
846
+
847
+ IMPORTANT: For each returned entity:
848
+ - Classify per system instruction rules (${atomicLabel} → isAtomic=false for works)
849
+ - year: The 4-digit year of creation, publication, or occurrence. Required if it is an Event/Work.
850
+
851
+ Examples:
852
+ - For a Person involved in a recent event: Return the named Event or Incident (e.g. "Killing of Renee Good", "2026 Minneapolis Protests").
853
+ - For an Ingredient (e.g. "Chicken"): Return specific Recipes.
854
+ - 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
+ - For an Artist: Return specific major Artworks (e.g., "Mona Lisa", "The Last Supper") and optionally a few key Exhibitions/Movements.
857
+ - For a Mathematician: Return specific named Papers (often coauthored).
858
+
859
+ CRITICAL BIPARTITE RULE:
860
+ - The Source Node "${nodeName}" is an ATOMIC entity.
861
+ - Therefore, ALL returned entities MUST be COMPOSITE entities (${compositeLabel}).
862
+ - DO NOT return other ${atomicLabel} entities (other people, actors, or characters).
863
+ - 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
+
865
+ 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."}`;
867
+
868
+ // console.log(`🤖 [Gemini] fetchPersonWorks Prompt for "${nodeName}":`, prompt);
869
+
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"],
937
+ },
938
+ },
939
+ })
940
+ : undefined,
941
+ });
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
+
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
+ });
999
+
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
+ );
1010
+ }
1011
+ }
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
+ return parsed;
1017
+ } catch (error) {
1018
+ console.error("Gemini API Error (Person Works):", error);
1019
+ return { works: [] };
1020
+ }
1021
+ };
1022
+
1023
+ export const fetchConnectionPath = async (
1024
+ start: string,
1025
+ end: string,
1026
+ context?: { startWiki?: string; endWiki?: string },
1027
+ ): Promise<PathResponse> => {
1028
+ if (shouldProxy()) {
1029
+ return callAiProxy("/api/ai/path", { start, end, context });
1030
+ }
1031
+
1032
+ try {
1033
+ const apiKey = await getLlmApiKey();
1034
+ if (!apiKey) {
1035
+ console.error("❌ [Gemini] fetchConnectionPath: No API key found");
1036
+ return { path: [], found: false };
1037
+ }
1038
+
1039
+ const useGemini = getLlmProvider() === "gemini";
1040
+ const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
1041
+
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
+ : "";
1046
+
1047
+ const prompt = `Find a connection path between "${start}" and "${end}".
1048
+ ${wikiPrompt}
1049
+
1050
+ Your goal is to find the most direct and historically significant connection path.
1051
+
1052
+ CRITICAL RULES:
1053
+ 1. The path must ALTERNATE between "Person" and "Event" (where "Event" includes organizations, programs, shows, works, projects, places, etc.; anything that is not a person).
1054
+ 2. A "Person" MUST NOT be connected directly to another "Person".
1055
+ 3. An "Event" MUST NOT be connected directly to another "Event".
1056
+ 4. Each step must be a direct and verifiable collaboration, affiliation, or relationship.
1057
+ 5. The path must be a continuous chain where each node is connected to the next.
1058
+ 6. For every "Event" that is an actual Event, Show, Program, Work, or Historical Occurrence, strictly provide the Year it occurred or was created in the "year" field. If it is a persistent entity without a clear year (like a Location or specialized Concept), year is optional.
1059
+ 7. MEDIA PERSONALITIES RULE: When connecting media personalities (journalists, hosts, actors, comedians), you MUST use specific TV programs, radio shows, movies, plays, or books they worked on together as the connecting "Event".
1060
+ - PREFER: Specific shared credits (e.g., "The Daily Show", "Crossfire", "Saturday Night Live").
1061
+ - AVOID: Broad networks or shared employers (e.g., "Fox News", "CNN", "NBC") unless no specific show exists.
1062
+ - AVOID: Broad professional categories (e.g., "Journalism", "Comedy").
1063
+
1064
+ 8. ACADEMIC & SCIENTIFIC RULE: For philosophers, scientists, and academics, you COMPLETE INTELLECTUAL SPECIFICITY.
1065
+ - HIGHEST PRIORITY: "Eponymous Concepts" (Paradoxes, Theorems, Laws, Constants named after them) that connect them (e.g., "Russell's Paradox", "Gödel's Incompleteness Theorems").
1066
+ - HIGH PRIORITY: "Direct Correspondence" (e.g., specific letter exchanges) and "Specific Co-authored Works" (books, papers).
1067
+ - STRICTLY FORBIDDEN: Do NOT return another Person (Name) as the connecting node. The connection MUST be a composite entity (Concept, Work, Meeting, Correspondence).
1068
+ - FORBIDDEN: Do NOT use "Direct Mentorship" unless you can name the specific Lab, University Department, or Project where it happened as the node.
1069
+ - FORBIDDEN: Do NOT use broad movements, schools, or circles (e.g., "Vienna Circle", "Analytic Philosophy", "Rationalism", "British Empiricism") as the primary connecting node if *any* direct intellectual work, paradox, or correspondence exists.
1070
+ - FORBIDDEN: Do NOT use "University of X" or "Fellowship at Y" unless they were there at the exact same time and collaborated.
1071
+
1072
+ 9. FAMOUS MEETING RULE:
1073
+ - Prioritize cases where two famous people finally meet each other in person or by some other direct one-on-one connection.
1074
+ - PREFER: Specific events (summits, premieres, dinners, lab meetings) over broad eras or movements.
1075
+ - AVOID: Broad eras (e.g., "World War II", "Civil Rights Movement") or shared workplaces (e.g., "Bell Labs", "Hollywood") unless no specific meeting or project exists.
1076
+ - Illustrative Example: Alan Turing -> Meeting at Bell Labs (1943) -> Claude Shannon (BETTER than Turing -> World War II -> Shannon).
1077
+ - Illustrative Example: Goethe -> Meeting at Teplitz -> Beethoven (BETTER than Goethe -> Romanticism -> Beethoven).
1078
+
1079
+ BIPARTITE ENFORCEMENT:
1080
+ - If Node A is a Person and Node B is a Person, the intermediary MUST be a COMPOSITE (Event/Work/Concept).
1081
+ - It CANNOT be another Person.
1082
+ - WRONG: Russell -> Peano -> Frege
1083
+ - RIGHT: Russell -> Peano Axioms -> Peano -> Letter to Frege -> Frege
1084
+
1085
+ Example valid path:
1086
+ Person (Isaac Asimov) -> Event (Star Trek) -> Person (Gene Roddenberry)
1087
+
1088
+ Identify a sequence of 1-4 intermediary entities to link "${start}" to "${end}".
1089
+
1090
+ Return JSON:
1091
+ {
1092
+ "path": [
1093
+ { "id": "${start}", "type": "Person", "description": "Short bio", "justification": "Start node", "year": 1950 },
1094
+ { "id": "Intermediary 1...", "type": "TV Program/Movie/etc", "description": "...", "justification": "Directly connected to the PREVIOUS node because...", "year": 1965 },
1095
+ { "id": "${end}", "type": "Person", "description": "...", "justification": "Directly connected to the PREVIOUS node because...", "year": 1990 }
1096
+ ]
1097
+ }`;
1098
+
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"],
1145
+ },
1146
+ },
1147
+ })
1148
+ : undefined,
1149
+ });
1150
+ const json = JSON.parse(cleanJson(text));
1151
+
1152
+ // Ensure the path starts with the start node and ends with the end node
1153
+ if (json.path && json.path.length > 0) {
1154
+ const first = json.path[0].id.toLowerCase();
1155
+ const last = json.path[json.path.length - 1].id.toLowerCase();
1156
+ const startLow = start.toLowerCase();
1157
+ const endLow = end.toLowerCase();
1158
+
1159
+ // If AI didn't include start/end nodes, prepend/append them
1160
+ if (!first.includes(startLow) && !startLow.includes(first)) {
1161
+ json.path.unshift({
1162
+ id: start,
1163
+ type: "Start",
1164
+ description: context?.startWiki?.substring(0, 100) || "Start node",
1165
+ justification: "Start of path",
1166
+ year: null,
1167
+ });
1168
+ }
1169
+ if (!last.includes(endLow) && !endLow.includes(last)) {
1170
+ json.path.push({
1171
+ id: end,
1172
+ type: "End",
1173
+ description: context?.endWiki?.substring(0, 100) || "End node",
1174
+ justification: "Destination",
1175
+ year: null,
1176
+ });
1177
+ }
1178
+ }
1179
+
1180
+ return json as PathResponse;
1181
+ } catch (error) {
1182
+ console.error("Gemini Pathfinding Error:", error);
1183
+ return { path: [], found: false };
1184
+ }
1185
+ };
1186
+
1187
+ export const findWikipediaTitle = async (
1188
+ name: string,
1189
+ description?: string,
1190
+ ): Promise<{ title: string; imageHint?: string } | null> => {
1191
+ if (shouldProxy()) {
1192
+ return callAiProxy("/api/ai/title", { name, description });
1193
+ }
1194
+
1195
+ const apiKey = await getLlmApiKey();
1196
+ if (!apiKey) return null;
1197
+ const useGemini = getLlmProvider() === "gemini";
1198
+ const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
1199
+
1200
+ const prompt = `Find the exact English Wikipedia article title for "${name}"${description ? ` described as "${description}"` : ""}.
1201
+ Also, if you know a specific Wikimedia Commons filename for a good portrait of this person/thing, include it.
1202
+
1203
+ Return JSON:
1204
+ {
1205
+ "title": "Exact Wikipedia Title",
1206
+ "imageHint": "Optional filename like 'File:Person Name.jpg' or null"
1207
+ }`;
1208
+
1209
+ 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));
1234
+ return {
1235
+ title: json.title,
1236
+ imageHint: json.imageHint,
1237
+ };
1238
+ } catch (e) {
1239
+ // console.warn("AI title lookup failed", e);
1240
+ return null;
1241
+ }
1242
+ };
1243
+
1244
+ // Optional: grounded lookup for org leadership using Google Search tool.
1245
+ // NOTE: This cannot use responseSchema/responseMimeType; we parse JSON from text.
1246
+ export const fetchOrgKeyPeopleBlockViaSearch = async (
1247
+ orgName: string,
1248
+ ): Promise<string | null> => {
1249
+ if (shouldProxy()) {
1250
+ return callAiProxy("/api/ai/search-org", { orgName });
1251
+ }
1252
+
1253
+ // Google Search grounding is Gemini-only in this codebase.
1254
+ if (getLlmProvider() !== "gemini") return null;
1255
+
1256
+ const apiKey = await getLlmApiKey();
1257
+ if (!apiKey) return null;
1258
+
1259
+ const name = String(orgName || "").trim();
1260
+ if (!name) return null;
1261
+
1262
+ const ai = new GoogleGenAI({ apiKey });
1263
+ const prompt = `Use Google Search to find reputable sources about "${name}".
1264
+
1265
+ Goal: extract founders and key leadership/creative roles for the organization/museum/venue.
1266
+
1267
+ Return STRICT JSON only (no prose):
1268
+ {
1269
+ "founders": [{"name": "Full Name", "evidence": "Short quote or paraphrase", "sourceUrl": "https://...", "sourceTitle": "Page title"}],
1270
+ "keyPeople": [{"name": "Full Name", "role": "Role", "evidence": "Short quote or paraphrase", "sourceUrl": "https://...", "sourceTitle": "Page title"}]
1271
+ }
1272
+
1273
+ Rules:
1274
+ - Prefer sources that explicitly state founder/creative director/CEO/etc.
1275
+ - If the founder is not explicitly stated, leave founders empty.
1276
+ - Only include people that are clearly tied to "${name}" (avoid name collisions).
1277
+ - If unsure, omit.`;
1278
+
1279
+ try {
1280
+ const response = await withRetry(
1281
+ () =>
1282
+ withTimeout(
1283
+ ai.models.generateContent({
1284
+ model: getGeminiModel(),
1285
+ contents: prompt,
1286
+ config: {
1287
+ systemInstruction:
1288
+ "You are a careful research assistant. Use Google Search for grounding and do not invent facts.",
1289
+ tools: [{ googleSearch: {} }],
1290
+ },
1291
+ }),
1292
+ 20000,
1293
+ "Org key-people search timed out",
1294
+ ),
1295
+ 4,
1296
+ 1000,
1297
+ );
1298
+
1299
+ const text = cleanJson(getResponseText(response));
1300
+ if (!text) return null;
1301
+ const json = JSON.parse(text) as any;
1302
+ const founders = Array.isArray(json?.founders) ? json.founders : [];
1303
+ const keyPeople = Array.isArray(json?.keyPeople) ? json.keyPeople : [];
1304
+
1305
+ const f = founders
1306
+ .filter((x: any) => x?.name && typeof x.name === "string")
1307
+ .slice(0, 10)
1308
+ .map((x: any) => ({
1309
+ name: String(x.name).trim(),
1310
+ evidence: x?.evidence ? String(x.evidence).trim() : "",
1311
+ sourceTitle: x?.sourceTitle ? String(x.sourceTitle).trim() : "",
1312
+ sourceUrl: x?.sourceUrl ? String(x.sourceUrl).trim() : "",
1313
+ }))
1314
+ .filter((x: any) => x.name);
1315
+
1316
+ const kp = keyPeople
1317
+ .filter((x: any) => x?.name && typeof x.name === "string")
1318
+ .slice(0, 15)
1319
+ .map((x: any) => ({
1320
+ name: String(x.name).trim(),
1321
+ role: x?.role ? String(x.role).trim() : "",
1322
+ evidence: x?.evidence ? String(x.evidence).trim() : "",
1323
+ sourceTitle: x?.sourceTitle ? String(x.sourceTitle).trim() : "",
1324
+ sourceUrl: x?.sourceUrl ? String(x.sourceUrl).trim() : "",
1325
+ }))
1326
+ .filter((x: any) => x.name);
1327
+
1328
+ if (f.length === 0 && kp.length === 0) return null;
1329
+
1330
+ const lines: string[] = [];
1331
+ if (f.length) {
1332
+ lines.push(`Founders: ${f.map((x) => x.name).join(", ")}`);
1333
+ }
1334
+ if (kp.length) {
1335
+ lines.push(
1336
+ `Key People: ${kp
1337
+ .map((x) => (x.role ? `${x.name} (${x.role})` : x.name))
1338
+ .join(", ")}`,
1339
+ );
1340
+ }
1341
+ const sources = [...f, ...kp]
1342
+ .map((x) =>
1343
+ x.sourceUrl ? `${x.sourceTitle || "Source"} — ${x.sourceUrl}` : "",
1344
+ )
1345
+ .filter(Boolean);
1346
+ const uniqueSources = Array.from(new Set(sources)).slice(0, 8);
1347
+
1348
+ return [
1349
+ `GOOGLE_SEARCH_GROUNDED (for "${name}")`,
1350
+ ...lines.map((l) => `- ${l}`),
1351
+ ...(uniqueSources.length
1352
+ ? ["Sources:", ...uniqueSources.map((s) => `- ${s}`)]
1353
+ : []),
1354
+ ].join("\n");
1355
+ } catch (e) {
1356
+ // console.warn("Org key-people search failed:", name, e);
1357
+ return null;
1358
+ }
1359
+ };