@johndimm/constellations 1.0.0 → 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.
- package/App.tsx +352 -70
- package/FullPageConstellations.tsx +7 -5
- package/components/AppConfirmDialog.tsx +1 -0
- package/components/AppHeader.tsx +69 -29
- package/components/AppNotifications.tsx +1 -0
- package/components/BrowsePeople.tsx +3 -0
- package/components/ControlPanel.tsx +46 -371
- package/components/Graph.tsx +251 -87
- package/components/HelpOverlay.tsx +1 -0
- package/components/NodeContextMenu.tsx +123 -3
- package/components/PeopleBrowserSidebar.tsx +15 -6
- package/components/Sidebar.tsx +46 -19
- package/components/TimelineView.tsx +1 -0
- package/embedded.css +38 -0
- package/hooks/useExpansion.ts +61 -229
- package/hooks/useGraphActions.ts +1 -0
- package/hooks/useGraphState.ts +75 -40
- package/hooks/useKioskMode.ts +1 -0
- package/hooks/useNodeClickHandler.ts +23 -15
- package/hooks/useSearchHandlers.ts +57 -19
- package/host.ts +1 -1
- package/index.css +17 -3
- package/package.json +4 -1
- package/services/aiService.ts +23 -0
- package/services/aiUtils.ts +216 -207
- package/services/cacheService.ts +1 -0
- package/services/crossrefService.ts +1 -0
- package/services/deepseekService.ts +467 -0
- package/services/geminiService.ts +532 -733
- package/services/graphUtils.ts +128 -18
- package/services/imageService.ts +18 -0
- package/services/openAlexService.ts +1 -0
- package/services/resolveImageForTitle.ts +458 -0
- package/services/wikipediaImage.ts +1 -0
- package/services/wikipediaService.ts +56 -46
- package/types.ts +3 -0
- package/utils/evidenceUtils.ts +1 -0
- package/utils/graphLogicUtils.ts +1 -0
- package/utils/wikiUtils.ts +14 -2
|
@@ -1,44 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { GoogleGenAI, Type } from "@google/genai";
|
|
2
|
-
import { GeminiResponse,
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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",
|
|
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
|
-
|
|
185
|
-
console.info("[LLM] proxy RESPONSE", endpoint, clipForLlmLog(JSON.stringify(data)));
|
|
186
|
-
return data;
|
|
135
|
+
return resp.json();
|
|
187
136
|
} catch (e: any) {
|
|
188
|
-
|
|
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",
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
347
|
-
description: json.description
|
|
384
|
+
type: s(json.type, "Event"),
|
|
385
|
+
description: s(json.description, ""),
|
|
348
386
|
isAtomic: !!json.isAtomic,
|
|
349
|
-
atomicType: json.atomicType
|
|
350
|
-
compositeType: json.compositeType
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
430
|
+
return { type: 'Event', description: '', isAtomic: false };
|
|
404
431
|
}
|
|
405
|
-
|
|
406
|
-
const
|
|
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
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
495
|
+
const json = parseJsonFromModelText(rawText);
|
|
472
496
|
// console.log("Classify response text:", text);
|
|
473
|
-
if (!
|
|
474
|
-
|
|
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:
|
|
477
|
-
description:
|
|
478
|
-
isAtomic: !!
|
|
479
|
-
atomicType:
|
|
480
|
-
compositeType:
|
|
481
|
-
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:
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
if (
|
|
515
|
-
console.
|
|
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
|
-
|
|
520
|
-
const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
|
|
537
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
521
538
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
)
|
|
561
|
-
|
|
562
|
-
|
|
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 ?
|
|
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) ?
|
|
577
|
-
${(compositeType || "").match(/^(Magazine|Newspaper|Journal|Periodical|Publication)$/i) ?
|
|
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
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
|
674
|
-
if (!
|
|
655
|
+
const parsed = parseJsonFromModelText(rawText) as GeminiResponse | null;
|
|
656
|
+
if (!parsed || !Array.isArray(parsed.people)) return { people: [] };
|
|
675
657
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
if (
|
|
753
|
-
console.
|
|
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
|
-
|
|
758
|
-
const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
|
|
694
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
759
695
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
766
|
-
|
|
701
|
+
const atomicLabel = atomicType || "ATOMIC entity";
|
|
702
|
+
const compositeLabel = compositeType || "COMPOSITE entity";
|
|
767
703
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
774
|
-
|
|
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
|
-
|
|
781
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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 ?
|
|
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
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
type: Type.
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
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
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
if (
|
|
1035
|
-
console.
|
|
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
|
-
|
|
1040
|
-
const gemini = useGemini ? new GoogleGenAI({ apiKey }) : null;
|
|
879
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
1041
880
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
|
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
|
|
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
|
|
1011
|
+
const apiKey = await getApiKey();
|
|
1196
1012
|
if (!apiKey) return null;
|
|
1197
|
-
const
|
|
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
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
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
|
|
1300
|
-
if (!
|
|
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(
|
|
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);
|