@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.
Files changed (39) hide show
  1. package/App.tsx +352 -70
  2. package/FullPageConstellations.tsx +7 -5
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +69 -29
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +46 -371
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +1 -0
  10. package/components/NodeContextMenu.tsx +123 -3
  11. package/components/PeopleBrowserSidebar.tsx +15 -6
  12. package/components/Sidebar.tsx +46 -19
  13. package/components/TimelineView.tsx +1 -0
  14. package/embedded.css +38 -0
  15. package/hooks/useExpansion.ts +61 -229
  16. package/hooks/useGraphActions.ts +1 -0
  17. package/hooks/useGraphState.ts +75 -40
  18. package/hooks/useKioskMode.ts +1 -0
  19. package/hooks/useNodeClickHandler.ts +23 -15
  20. package/hooks/useSearchHandlers.ts +57 -19
  21. package/host.ts +1 -1
  22. package/index.css +17 -3
  23. package/package.json +4 -1
  24. package/services/aiService.ts +23 -0
  25. package/services/aiUtils.ts +216 -207
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +467 -0
  29. package/services/geminiService.ts +532 -733
  30. package/services/graphUtils.ts +128 -18
  31. package/services/imageService.ts +18 -0
  32. package/services/openAlexService.ts +1 -0
  33. package/services/resolveImageForTitle.ts +458 -0
  34. package/services/wikipediaImage.ts +1 -0
  35. package/services/wikipediaService.ts +56 -46
  36. package/types.ts +3 -0
  37. package/utils/evidenceUtils.ts +1 -0
  38. package/utils/graphLogicUtils.ts +1 -0
  39. 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
+ });