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