@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
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { GeminiResponse, PersonWorksResponse, PathResponse } from "../types";
|
|
3
|
+
import { parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, readBundledEnv } from "./aiUtils";
|
|
4
|
+
import type { LockedPair } from "./geminiService";
|
|
5
|
+
|
|
6
|
+
export type { LockedPair };
|
|
7
|
+
|
|
8
|
+
const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
|
|
9
|
+
const DEFAULT_MODEL = "deepseek-chat";
|
|
10
|
+
|
|
11
|
+
const TIMEOUT_MS = 60000;
|
|
12
|
+
const CLASSIFY_TIMEOUT_MS = 15000;
|
|
13
|
+
|
|
14
|
+
function getDeepSeekApiKey(): string {
|
|
15
|
+
return readBundledEnv("VITE_DEEPSEEK_API_KEY");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function shouldProxy(): boolean {
|
|
19
|
+
if (typeof window === "undefined") return false;
|
|
20
|
+
if ((window as any).__PRERENDER_INJECTED) return false;
|
|
21
|
+
return !!getEnvCacheUrl();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function callAiProxy(endpoint: string, body: any) {
|
|
25
|
+
const baseUrl = getEnvCacheUrl();
|
|
26
|
+
const url = new URL(endpoint, baseUrl || (typeof window !== "undefined" ? window.location.origin : "")).toString();
|
|
27
|
+
const resp = await fetch(url, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify(body),
|
|
31
|
+
});
|
|
32
|
+
if (!resp.ok) throw new Error(`AI Proxy Error (${resp.status}): ${await resp.text()}`);
|
|
33
|
+
return resp.json();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function callDeepSeek(system: string, user: string, timeoutMs = TIMEOUT_MS): Promise<string> {
|
|
37
|
+
const apiKey = getDeepSeekApiKey();
|
|
38
|
+
if (!apiKey) throw new Error("No VITE_DEEPSEEK_API_KEY set");
|
|
39
|
+
|
|
40
|
+
const res = await fetch(DEEPSEEK_API_URL, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
Authorization: `Bearer ${apiKey}`,
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
model: DEFAULT_MODEL,
|
|
48
|
+
messages: [
|
|
49
|
+
{ role: "system", content: system },
|
|
50
|
+
{ role: "user", content: user },
|
|
51
|
+
],
|
|
52
|
+
response_format: { type: "json_object" },
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const err = await res.text();
|
|
58
|
+
throw new Error(`DeepSeek API error (${res.status}): ${err}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
return data.choices?.[0]?.message?.content ?? "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SYSTEM_INSTRUCTION = `
|
|
66
|
+
You are a Bipartite Graph Generator.
|
|
67
|
+
Your goal is to build a graph that alternates between an "Atomic" type and a "Composite" type.
|
|
68
|
+
|
|
69
|
+
BIPARTITE STRUCTURE:
|
|
70
|
+
A bipartite graph alternates between an "Atomic" entity type and a "Composite" entity type.
|
|
71
|
+
- Atomic: Fundamental building blocks (e.g., individual people, ingredients, symptoms, authors, actors, components)
|
|
72
|
+
- Composite: Collections or works (e.g., events, recipes, diseases, papers, movies, products, organizations)
|
|
73
|
+
|
|
74
|
+
Common bipartite pairs include:
|
|
75
|
+
- Person ↔ Event (works, historical events, organizations, movements)
|
|
76
|
+
- Ingredient ↔ Recipe
|
|
77
|
+
- Symptom ↔ Disease
|
|
78
|
+
- Author ↔ Paper
|
|
79
|
+
- Actor ↔ Movie
|
|
80
|
+
- Component ↔ Product
|
|
81
|
+
- Character ↔ Novel
|
|
82
|
+
|
|
83
|
+
CRITICAL EXAMPLES TO PREVENT MISCLASSIFICATION:
|
|
84
|
+
- "The Godfather" → COMPOSITE (type: Movie, isAtomic: false), pair: Actor ↔ Movie
|
|
85
|
+
- "Marlon Brando" → ATOMIC (type: Actor, isAtomic: true), pair: Actor ↔ Movie
|
|
86
|
+
- Movies/books/albums are ALWAYS composite (created BY actors/authors/musicians)
|
|
87
|
+
|
|
88
|
+
Core Rules:
|
|
89
|
+
1. If the Source is a Composite, return 8-10 distinct Atomics that are meaningfully connected to it.
|
|
90
|
+
2. If the Source is an Atomic, return 8-10 distinct Composites that it is meaningfully connected to.
|
|
91
|
+
3. Use Title Case for all names.
|
|
92
|
+
4. Return only factually correct information. Do not hallucinate.
|
|
93
|
+
5. Return strict JSON only — no prose, no markdown fences.
|
|
94
|
+
|
|
95
|
+
Output Format Rules:
|
|
96
|
+
- wikipediaTitle: Always provide the canonical English Wikipedia article title.
|
|
97
|
+
- evidenceSnippet: Provide a 1-sentence evidence snippet explaining the connection.
|
|
98
|
+
- evidencePageTitle: Set to the Wikipedia article title the snippet is from.
|
|
99
|
+
|
|
100
|
+
Entity Classification:
|
|
101
|
+
- isAtomic: true for INDIVIDUAL PEOPLE/CHARACTERS, false for WORKS/GROUPS/ORGANIZATIONS.
|
|
102
|
+
|
|
103
|
+
CRITICAL — DO NOT return:
|
|
104
|
+
- YouTube channel names, usernames, or video titles (e.g. "pianetapapalla62", "MyChannel! Video Title")
|
|
105
|
+
- Strings that are a username concatenated with a video title
|
|
106
|
+
- Any entity whose name looks like an online username (all-lowercase + digits, no spaces)
|
|
107
|
+
- Raw video metadata from any platform
|
|
108
|
+
Only return canonical real-world named entities: people, musical works, books, films, organizations, historical events.
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
// Rejects nodes that look like YouTube usernames or video titles rather than real entities.
|
|
112
|
+
function isValidEntityName(name: string): boolean {
|
|
113
|
+
if (!name || typeof name !== "string") return false;
|
|
114
|
+
const trimmed = name.trim();
|
|
115
|
+
// Reject if it contains "! " — YouTube video title separator pattern
|
|
116
|
+
if (trimmed.includes("! ")) return false;
|
|
117
|
+
// Reject if it looks like a username: no spaces, mixes lowercase letters and digits
|
|
118
|
+
if (!/\s/.test(trimmed) && /[a-z]/.test(trimmed) && /\d/.test(trimmed) && trimmed.length > 4) return false;
|
|
119
|
+
// Reject very long single-word strings (likely concatenated junk)
|
|
120
|
+
if (!/\s/.test(trimmed) && trimmed.length > 20) return false;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const classifyStartPair = async (
|
|
125
|
+
term: string,
|
|
126
|
+
wikiContext?: string
|
|
127
|
+
): Promise<{
|
|
128
|
+
type: string;
|
|
129
|
+
description: string;
|
|
130
|
+
isAtomic: boolean;
|
|
131
|
+
atomicType: string;
|
|
132
|
+
compositeType: string;
|
|
133
|
+
reasoning: string;
|
|
134
|
+
}> => {
|
|
135
|
+
const fallback = {
|
|
136
|
+
type: "Event",
|
|
137
|
+
description: "",
|
|
138
|
+
isAtomic: false,
|
|
139
|
+
atomicType: "Person",
|
|
140
|
+
compositeType: "Event",
|
|
141
|
+
reasoning: "Default fallback.",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (shouldProxy()) return callAiProxy("/api/ai/classify-start", { term, wikiContext });
|
|
145
|
+
|
|
146
|
+
const apiKey = getDeepSeekApiKey();
|
|
147
|
+
if (!apiKey) return fallback;
|
|
148
|
+
|
|
149
|
+
const prompt = `Choose the most appropriate bipartite pair for: "${term}".
|
|
150
|
+
|
|
151
|
+
Rules:
|
|
152
|
+
- If "${term}" is an individual human, it is ATOMIC.
|
|
153
|
+
- If "${term}" is a WORK (movie, album, book, film, TV show), it is ALWAYS COMPOSITE.
|
|
154
|
+
- If "${term}" is an organization/institution/band, it is ALWAYS COMPOSITE.
|
|
155
|
+
|
|
156
|
+
Return JSON with exactly these fields:
|
|
157
|
+
{
|
|
158
|
+
"type": "string",
|
|
159
|
+
"description": "string",
|
|
160
|
+
"isAtomic": boolean,
|
|
161
|
+
"atomicType": "string",
|
|
162
|
+
"compositeType": "string",
|
|
163
|
+
"reasoning": "string"
|
|
164
|
+
}`;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const raw = await withTimeout(
|
|
168
|
+
withRetry(() => callDeepSeek(SYSTEM_INSTRUCTION, prompt), 3, 1000),
|
|
169
|
+
CLASSIFY_TIMEOUT_MS,
|
|
170
|
+
"classifyStartPair timed out"
|
|
171
|
+
);
|
|
172
|
+
const json = parseJsonFromModelText(raw) as Record<string, unknown> | null;
|
|
173
|
+
if (!json) return fallback;
|
|
174
|
+
const s = (v: unknown, fb: string) => (typeof v === "string" && v ? v : fb);
|
|
175
|
+
return {
|
|
176
|
+
type: s(json.type, "Event"),
|
|
177
|
+
description: s(json.description, ""),
|
|
178
|
+
isAtomic: !!json.isAtomic,
|
|
179
|
+
atomicType: s(json.atomicType, "Person"),
|
|
180
|
+
compositeType: s(json.compositeType, "Event"),
|
|
181
|
+
reasoning: s(json.reasoning, ""),
|
|
182
|
+
};
|
|
183
|
+
} catch (e) {
|
|
184
|
+
console.warn("[DeepSeek] classifyStartPair failed:", String(e).slice(0, 200));
|
|
185
|
+
return fallback;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const classifyEntity = async (
|
|
190
|
+
term: string,
|
|
191
|
+
wikiContext?: string
|
|
192
|
+
): Promise<{
|
|
193
|
+
type: string;
|
|
194
|
+
description: string;
|
|
195
|
+
isAtomic: boolean;
|
|
196
|
+
atomicType?: string;
|
|
197
|
+
compositeType?: string;
|
|
198
|
+
reasoning?: string;
|
|
199
|
+
}> => {
|
|
200
|
+
const fallback = { type: "Event", description: "", isAtomic: false };
|
|
201
|
+
|
|
202
|
+
if (shouldProxy()) return callAiProxy("/api/ai/classify", { term, wikiContext });
|
|
203
|
+
|
|
204
|
+
const apiKey = getDeepSeekApiKey();
|
|
205
|
+
if (!apiKey) return fallback;
|
|
206
|
+
|
|
207
|
+
const wikiPrompt = wikiContext ? `\n\nUSE THIS VERIFIED INFORMATION:\n${wikiContext}\n` : "";
|
|
208
|
+
|
|
209
|
+
const prompt = `Classify "${term}".${wikiPrompt}
|
|
210
|
+
|
|
211
|
+
Determine if it is Atomic (individual human, ingredient, symptom) or Composite (movie, recipe, disease, organization, historical event).
|
|
212
|
+
|
|
213
|
+
Return JSON:
|
|
214
|
+
{
|
|
215
|
+
"type": "string",
|
|
216
|
+
"description": "string",
|
|
217
|
+
"isAtomic": boolean,
|
|
218
|
+
"atomicType": "string",
|
|
219
|
+
"compositeType": "string",
|
|
220
|
+
"reasoning": "string"
|
|
221
|
+
}`;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const raw = await withRetry(
|
|
225
|
+
() => withTimeout(callDeepSeek(SYSTEM_INSTRUCTION, prompt), CLASSIFY_TIMEOUT_MS, "classifyEntity timed out"),
|
|
226
|
+
3,
|
|
227
|
+
1000
|
|
228
|
+
);
|
|
229
|
+
const json = parseJsonFromModelText(raw) as Record<string, unknown> | null;
|
|
230
|
+
if (!json) return fallback;
|
|
231
|
+
return {
|
|
232
|
+
type: (json.type as string) || "Event",
|
|
233
|
+
description: (json.description as string) || "",
|
|
234
|
+
isAtomic: !!json.isAtomic,
|
|
235
|
+
atomicType: json.atomicType as string | undefined,
|
|
236
|
+
compositeType: json.compositeType as string | undefined,
|
|
237
|
+
reasoning: json.reasoning as string | undefined,
|
|
238
|
+
};
|
|
239
|
+
} catch (e) {
|
|
240
|
+
console.warn("[DeepSeek] classifyEntity failed:", String(e).slice(0, 200));
|
|
241
|
+
return fallback;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const fetchConnections = async (
|
|
246
|
+
nodeName: string,
|
|
247
|
+
context?: string,
|
|
248
|
+
excludeNodes: string[] = [],
|
|
249
|
+
wikiContext?: string,
|
|
250
|
+
wikipediaId?: string,
|
|
251
|
+
atomicType?: string,
|
|
252
|
+
compositeType?: string,
|
|
253
|
+
mentioningPageTitles?: string[]
|
|
254
|
+
): Promise<GeminiResponse> => {
|
|
255
|
+
if (shouldProxy()) {
|
|
256
|
+
return callAiProxy("/api/ai/connections", { nodeName, context, excludeNodes, wikiContext, wikipediaId, atomicType, compositeType, mentioningPageTitles });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const apiKey = getDeepSeekApiKey();
|
|
260
|
+
if (!apiKey) return { people: [] };
|
|
261
|
+
|
|
262
|
+
const atomicLabel = atomicType || "ATOMIC entity";
|
|
263
|
+
const compositeLabel = compositeType || "COMPOSITE entity";
|
|
264
|
+
const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
|
|
265
|
+
const contextualPrompt = context
|
|
266
|
+
? `Analyze: "${nodeName}"${wikiIdStr} specifically in the context of "${context}".`
|
|
267
|
+
: `Analyze: "${nodeName}"${wikiIdStr}.`;
|
|
268
|
+
const wikiPrompt = wikiContext ? `\n\nUSE THIS VERIFIED INFORMATION:\n${wikiContext}\n` : "";
|
|
269
|
+
const excludePrompt = excludeNodes.length > 0
|
|
270
|
+
? `\nDO NOT include these already known connections: ${JSON.stringify(excludeNodes)}. Find NEW connections.`
|
|
271
|
+
: "";
|
|
272
|
+
const mentionPrompt = mentioningPageTitles?.length
|
|
273
|
+
? `\nThis entity is mentioned in: ${mentioningPageTitles.join(", ")}. Investigate these contexts.`
|
|
274
|
+
: "";
|
|
275
|
+
|
|
276
|
+
const prompt = `${contextualPrompt}${wikiPrompt}${mentionPrompt}${excludePrompt}
|
|
277
|
+
|
|
278
|
+
Return ${excludeNodes.length > 0 ? "6-8 NEW" : "5-6 key"} ${atomicLabel} entities that are fundamental components of this ${compositeLabel}.
|
|
279
|
+
|
|
280
|
+
Source Node: ${nodeName} (Type: ${compositeLabel})
|
|
281
|
+
|
|
282
|
+
CRITICAL BIPARTITE RULE: The Source is COMPOSITE, so ALL returned entities MUST be ATOMIC (${atomicLabel}).
|
|
283
|
+
${atomicType?.toLowerCase() === "person" ? "CRITICAL: Return ONLY specific individual people with proper names. NO organizations, groups, or locations." : ""}
|
|
284
|
+
|
|
285
|
+
Return JSON:
|
|
286
|
+
{
|
|
287
|
+
"people": [
|
|
288
|
+
{
|
|
289
|
+
"name": "string",
|
|
290
|
+
"isAtomic": true,
|
|
291
|
+
"wikipediaTitle": "string",
|
|
292
|
+
"role": "string",
|
|
293
|
+
"description": "string",
|
|
294
|
+
"evidenceSnippet": "string",
|
|
295
|
+
"evidencePageTitle": "string"
|
|
296
|
+
}
|
|
297
|
+
]
|
|
298
|
+
}`;
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const raw = await withRetry(
|
|
302
|
+
() => withTimeout(callDeepSeek(SYSTEM_INSTRUCTION, prompt), TIMEOUT_MS, "fetchConnections timed out"),
|
|
303
|
+
4,
|
|
304
|
+
1000
|
|
305
|
+
);
|
|
306
|
+
const parsed = parseJsonFromModelText(raw) as GeminiResponse | null;
|
|
307
|
+
if (!parsed || !Array.isArray(parsed.people)) return { people: [] };
|
|
308
|
+
parsed.people = parsed.people
|
|
309
|
+
.filter(p => isValidEntityName(p.name))
|
|
310
|
+
.map(p => ({ ...p, isAtomic: true }));
|
|
311
|
+
return parsed;
|
|
312
|
+
} catch (e) {
|
|
313
|
+
console.error("[DeepSeek] fetchConnections error:", e);
|
|
314
|
+
return { people: [] };
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
export const fetchPersonWorks = async (
|
|
319
|
+
nodeName: string,
|
|
320
|
+
excludeNodes: string[] = [],
|
|
321
|
+
wikiContext?: string,
|
|
322
|
+
wikipediaId?: string,
|
|
323
|
+
atomicType?: string,
|
|
324
|
+
compositeType?: string,
|
|
325
|
+
mentioningPageTitles?: string[]
|
|
326
|
+
): Promise<PersonWorksResponse> => {
|
|
327
|
+
if (shouldProxy()) {
|
|
328
|
+
return callAiProxy("/api/ai/works", { nodeName, excludeNodes, wikiContext, wikipediaId, atomicType, compositeType, mentioningPageTitles });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const apiKey = getDeepSeekApiKey();
|
|
332
|
+
if (!apiKey) return { works: [] };
|
|
333
|
+
|
|
334
|
+
const atomicLabel = atomicType || "ATOMIC entity";
|
|
335
|
+
const compositeLabel = compositeType || "COMPOSITE entity";
|
|
336
|
+
const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
|
|
337
|
+
const wikiPrompt = wikiContext ? `\n\nUSE THIS VERIFIED INFORMATION:\n${wikiContext}\n` : "";
|
|
338
|
+
const mentionPrompt = mentioningPageTitles?.length
|
|
339
|
+
? `\nThis person is mentioned in: ${mentioningPageTitles.join(", ")}. Prioritize these as primary ${compositeLabel} connections.`
|
|
340
|
+
: "";
|
|
341
|
+
const contextPrompt = excludeNodes.length > 0
|
|
342
|
+
? `Already in graph: ${JSON.stringify(excludeNodes)}. Return 6-8 NEW significant ${compositeLabel} entities for "${nodeName}"${wikiIdStr}.`
|
|
343
|
+
: `List 5-6 distinct, significant ${compositeLabel} entities that "${nodeName}"${wikiIdStr} belongs to or created.`;
|
|
344
|
+
|
|
345
|
+
const prompt = `${wikiPrompt}${mentionPrompt}${contextPrompt}
|
|
346
|
+
|
|
347
|
+
CRITICAL BIPARTITE RULE: "${nodeName}" is ATOMIC, so ALL returned entities MUST be COMPOSITE (${compositeLabel}).
|
|
348
|
+
|
|
349
|
+
Return JSON:
|
|
350
|
+
{
|
|
351
|
+
"works": [
|
|
352
|
+
{
|
|
353
|
+
"entity": "string",
|
|
354
|
+
"isAtomic": false,
|
|
355
|
+
"wikipediaTitle": "string",
|
|
356
|
+
"type": "string",
|
|
357
|
+
"description": "string",
|
|
358
|
+
"role": "string",
|
|
359
|
+
"year": 1990,
|
|
360
|
+
"evidenceSnippet": "string",
|
|
361
|
+
"evidencePageTitle": "string"
|
|
362
|
+
}
|
|
363
|
+
]
|
|
364
|
+
}`;
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const raw = await withRetry(
|
|
368
|
+
() => withTimeout(callDeepSeek(SYSTEM_INSTRUCTION, prompt), TIMEOUT_MS, "fetchPersonWorks timed out"),
|
|
369
|
+
4,
|
|
370
|
+
1000
|
|
371
|
+
);
|
|
372
|
+
const parsed = parseJsonFromModelText(raw) as PersonWorksResponse | null;
|
|
373
|
+
if (!parsed || !Array.isArray(parsed.works)) return { works: [] };
|
|
374
|
+
parsed.works = parsed.works
|
|
375
|
+
.filter(w => isValidEntityName(w.entity))
|
|
376
|
+
.map(w => ({ ...w, isAtomic: false }));
|
|
377
|
+
return parsed;
|
|
378
|
+
} catch (e) {
|
|
379
|
+
console.error("[DeepSeek] fetchPersonWorks error:", e);
|
|
380
|
+
return { works: [] };
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
export const fetchConnectionPath = async (
|
|
385
|
+
start: string,
|
|
386
|
+
end: string,
|
|
387
|
+
context?: { startWiki?: string; endWiki?: string }
|
|
388
|
+
): Promise<PathResponse> => {
|
|
389
|
+
if (shouldProxy()) return callAiProxy("/api/ai/path", { start, end, context });
|
|
390
|
+
|
|
391
|
+
const apiKey = getDeepSeekApiKey();
|
|
392
|
+
if (!apiKey) return { path: [], found: false };
|
|
393
|
+
|
|
394
|
+
const wikiPrompt = (context?.startWiki || context?.endWiki)
|
|
395
|
+
? `\n\nVERIFIED INFO:\n${context?.startWiki ? `[${start}]: ${context.startWiki}\n` : ""}${context?.endWiki ? `[${end}]: ${context.endWiki}\n` : ""}`
|
|
396
|
+
: "";
|
|
397
|
+
|
|
398
|
+
const prompt = `Find a connection path between "${start}" and "${end}".${wikiPrompt}
|
|
399
|
+
|
|
400
|
+
Rules:
|
|
401
|
+
1. The path MUST ALTERNATE between Person and Event (organizations, works, projects, places count as Event).
|
|
402
|
+
2. A Person MUST NOT connect directly to another Person.
|
|
403
|
+
3. Each step must be a direct, verifiable collaboration or relationship.
|
|
404
|
+
4. Use 1-4 intermediary entities.
|
|
405
|
+
|
|
406
|
+
Return JSON:
|
|
407
|
+
{
|
|
408
|
+
"path": [
|
|
409
|
+
{ "id": "string", "type": "string", "description": "string", "justification": "string", "year": 1950 }
|
|
410
|
+
]
|
|
411
|
+
}`;
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const raw = await withTimeout(
|
|
415
|
+
callDeepSeek(SYSTEM_INSTRUCTION, prompt),
|
|
416
|
+
45000,
|
|
417
|
+
"fetchConnectionPath timed out"
|
|
418
|
+
);
|
|
419
|
+
const json = parseJsonFromModelText(raw) as { path?: PathResponse["path"] } | null;
|
|
420
|
+
if (!json || !Array.isArray(json.path)) return { path: [], found: false };
|
|
421
|
+
return { path: json.path, found: json.path.length > 0 };
|
|
422
|
+
} catch (e) {
|
|
423
|
+
console.error("[DeepSeek] fetchConnectionPath error:", e);
|
|
424
|
+
return { path: [], found: false };
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
export const findWikipediaTitle = async (
|
|
429
|
+
name: string,
|
|
430
|
+
description?: string
|
|
431
|
+
): Promise<{ title: string; imageHint?: string } | null> => {
|
|
432
|
+
if (shouldProxy()) return callAiProxy("/api/ai/title", { name, description });
|
|
433
|
+
|
|
434
|
+
const apiKey = getDeepSeekApiKey();
|
|
435
|
+
if (!apiKey) return null;
|
|
436
|
+
|
|
437
|
+
const prompt = `Find the exact English Wikipedia article title for "${name}"${description ? ` described as "${description}"` : ""}.
|
|
438
|
+
|
|
439
|
+
Return JSON:
|
|
440
|
+
{
|
|
441
|
+
"title": "Exact Wikipedia Title",
|
|
442
|
+
"imageHint": "Optional Wikimedia Commons filename like 'File:Name.jpg' or null"
|
|
443
|
+
}`;
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const raw = await withTimeout(
|
|
447
|
+
callDeepSeek("You are a Wikipedia lookup assistant. Return strict JSON only.", prompt),
|
|
448
|
+
10000,
|
|
449
|
+
"findWikipediaTitle timed out"
|
|
450
|
+
);
|
|
451
|
+
const json = parseJsonFromModelText(raw) as { title?: string; imageHint?: string } | null;
|
|
452
|
+
if (!json || typeof json.title !== "string" || !json.title.trim()) return null;
|
|
453
|
+
return { title: json.title, imageHint: json.imageHint };
|
|
454
|
+
} catch {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
export const defaultStartPairResult = (reason: string) => ({
|
|
461
|
+
type: "Event",
|
|
462
|
+
description: "",
|
|
463
|
+
isAtomic: false,
|
|
464
|
+
atomicType: "Person",
|
|
465
|
+
compositeType: "Event",
|
|
466
|
+
reasoning: reason,
|
|
467
|
+
});
|