@johndimm/constellations 1.0.1 → 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 -4
- 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/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 +2 -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
package/hooks/useExpansion.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { useState, useCallback } from 'react';
|
|
2
3
|
import { GraphNode, GraphLink } from '../types';
|
|
3
|
-
import { fetchConnections, fetchPersonWorks, classifyEntity, fetchOrgKeyPeopleBlockViaSearch, LockedPair } from '../services/
|
|
4
|
+
import { fetchConnections, fetchPersonWorks, classifyEntity, fetchOrgKeyPeopleBlockViaSearch, LockedPair } from '../services/aiService';
|
|
4
5
|
import { fetchWikipediaSummary, fetchWikipediaExtract, fetchWikidataKeyPeopleForTitle, fetchWikidataCastForTitle } from '../services/wikipediaService';
|
|
5
6
|
import {
|
|
6
7
|
searchOpenAlexAuthor,
|
|
@@ -24,13 +25,6 @@ import {
|
|
|
24
25
|
roleLooksLikeJobTitle
|
|
25
26
|
} from '../utils/evidenceUtils';
|
|
26
27
|
import { getLinkKey, looksLikeScreenWork, isBadListPage } from '../utils/graphLogicUtils';
|
|
27
|
-
import { fetchWithTimeout, withTimeout } from '../services/aiUtils';
|
|
28
|
-
import { clipForLlmLog } from '../services/aiUtils';
|
|
29
|
-
|
|
30
|
-
const WIKI_SUMMARY_TIMEOUT_MS = 15000;
|
|
31
|
-
/** Hung cache PostgreSQL / slow disk should not strand node spinners indefinitely. */
|
|
32
|
-
const CACHE_GET_TIMEOUT_MS = 25_000;
|
|
33
|
-
const CACHE_POST_TIMEOUT_MS = 90_000;
|
|
34
28
|
|
|
35
29
|
interface UseExpansionOptions {
|
|
36
30
|
graphDataRef: React.MutableRefObject<{ nodes: GraphNode[], links: GraphLink[] }>;
|
|
@@ -41,12 +35,12 @@ interface UseExpansionOptions {
|
|
|
41
35
|
lockedPairRef: React.MutableRefObject<LockedPair>;
|
|
42
36
|
nodesRef: React.MutableRefObject<GraphNode[]>;
|
|
43
37
|
selectedNodeRef: React.MutableRefObject<GraphNode | null>;
|
|
44
|
-
autoExpandMoreDoneRef: React.MutableRefObject<Set<number>>;
|
|
38
|
+
autoExpandMoreDoneRef: React.MutableRefObject<Set<string | number>>;
|
|
45
39
|
cacheEnabled: boolean;
|
|
46
40
|
cacheBaseUrl: string;
|
|
47
41
|
ENABLE_ACADEMIC_CORPORA: boolean;
|
|
48
42
|
ENABLE_WEB_SEARCH: boolean;
|
|
49
|
-
loadNodeImage: (nodeId: number, title: string, context?: string, fallbackNode?: any, opts?: any) => Promise<void>;
|
|
43
|
+
loadNodeImage: (nodeId: number | string, title: string, context?: string, fallbackNode?: any, opts?: any) => Promise<void>;
|
|
50
44
|
saveCacheNodeMeta: (nodeId: number | string, meta: any, fallbackNode?: any) => Promise<void>;
|
|
51
45
|
setNewlyExpandedNodeIds: (ids: (number | string)[]) => void;
|
|
52
46
|
setExpandingNodeId: (id: number | string | null) => void;
|
|
@@ -56,27 +50,24 @@ interface UseExpansionOptions {
|
|
|
56
50
|
exploreTerm: string;
|
|
57
51
|
isTextOnly: boolean;
|
|
58
52
|
graphRef: React.RefObject<any>;
|
|
59
|
-
setNotification?: (n: { message: string; type: 'success' | 'error' }) => void;
|
|
60
53
|
}
|
|
61
54
|
|
|
62
55
|
export function useExpansion(options: UseExpansionOptions) {
|
|
63
|
-
const expansionInflightRef = useRef(0);
|
|
64
56
|
const {
|
|
65
57
|
graphDataRef, setGraphData, setIsProcessing, setError,
|
|
66
58
|
searchIdRef, lockedPairRef, nodesRef, selectedNodeRef,
|
|
67
59
|
autoExpandMoreDoneRef, cacheEnabled, cacheBaseUrl,
|
|
68
60
|
ENABLE_ACADEMIC_CORPORA, ENABLE_WEB_SEARCH, loadNodeImage, saveCacheNodeMeta,
|
|
69
61
|
setNewlyExpandedNodeIds, setExpandingNodeId, setNewChildNodeIds,
|
|
70
|
-
setSelectedNode, setSelectedLink, exploreTerm, isTextOnly, graphRef
|
|
71
|
-
setNotification,
|
|
62
|
+
setSelectedNode, setSelectedLink, exploreTerm, isTextOnly, graphRef
|
|
72
63
|
} = options;
|
|
73
64
|
|
|
74
|
-
const fetchCacheExpansion = useCallback(async (sourceId: number) => {
|
|
65
|
+
const fetchCacheExpansion = useCallback(async (sourceId: number | string) => {
|
|
75
66
|
if (!cacheEnabled) return null;
|
|
76
67
|
const url = new URL("/expansion", cacheBaseUrl);
|
|
77
68
|
url.searchParams.set("sourceId", sourceId.toString());
|
|
78
69
|
try {
|
|
79
|
-
const res = await
|
|
70
|
+
const res = await fetch(url.toString());
|
|
80
71
|
if (!res.ok) return null;
|
|
81
72
|
return res.json();
|
|
82
73
|
} catch (e) {
|
|
@@ -88,15 +79,11 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
88
79
|
const saveCacheExpansion = useCallback(async (sourceId: number | string, nodes: any[]) => {
|
|
89
80
|
if (!cacheEnabled) return null;
|
|
90
81
|
try {
|
|
91
|
-
const res = await
|
|
92
|
-
|
|
93
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
body: JSON.stringify({ sourceId, nodes }),
|
|
97
|
-
},
|
|
98
|
-
CACHE_POST_TIMEOUT_MS,
|
|
99
|
-
);
|
|
82
|
+
const res = await fetch(new URL("/expansion", cacheBaseUrl).toString(), {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ sourceId, nodes })
|
|
86
|
+
});
|
|
100
87
|
if (res.ok) {
|
|
101
88
|
const data = await res.json();
|
|
102
89
|
return data.idMap as Record<string, number> | undefined;
|
|
@@ -121,21 +108,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
121
108
|
const guardId = searchIdRef.current;
|
|
122
109
|
const isStale = () => searchIdRef.current !== guardId;
|
|
123
110
|
|
|
124
|
-
|
|
125
|
-
const clearThisNodeLoading = () => {
|
|
126
|
-
setGraphData((prev) => ({
|
|
127
|
-
...prev,
|
|
128
|
-
nodes: prev.nodes.map((n) =>
|
|
129
|
-
String(n.id) === String(node.id) ? { ...n, isLoading: false } : n,
|
|
130
|
-
),
|
|
131
|
-
}));
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
// Do not block on isLoading: a crashed/stale expansion would strand the node forever.
|
|
135
|
-
if (!forceMore && node.expanded) {
|
|
136
|
-
console.info("[Expansion] skip (already expanded)", { title: node.title, id: node.id });
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
111
|
+
if (!forceMore && (node.expanded || node.isLoading)) return;
|
|
139
112
|
|
|
140
113
|
|
|
141
114
|
|
|
@@ -143,67 +116,36 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
143
116
|
if (isStale()) return;
|
|
144
117
|
setGraphData(prev => ({
|
|
145
118
|
...prev,
|
|
146
|
-
nodes: prev.nodes.map(n =>
|
|
119
|
+
nodes: prev.nodes.map(n => n.id === node.id ? { ...n, isLoading: true } : n)
|
|
147
120
|
}));
|
|
148
121
|
|
|
149
122
|
const loadingGuard = setTimeout(() => {
|
|
150
123
|
if (isStale()) return;
|
|
151
124
|
setGraphData(prev => ({
|
|
152
125
|
...prev,
|
|
153
|
-
nodes: prev.nodes.map(n =>
|
|
126
|
+
nodes: prev.nodes.map(n => n.id === node.id ? { ...n, isLoading: true } : n)
|
|
154
127
|
}));
|
|
155
128
|
}, 0);
|
|
156
129
|
|
|
157
|
-
expansionInflightRef.current += 1;
|
|
158
130
|
setIsProcessing(true);
|
|
159
131
|
setError(null);
|
|
160
132
|
|
|
161
133
|
try {
|
|
162
|
-
|
|
163
|
-
title: node.title,
|
|
164
|
-
id: node.id,
|
|
165
|
-
expanded: !!node.expanded,
|
|
166
|
-
isLoading: !!node.isLoading,
|
|
167
|
-
cacheEnabled,
|
|
168
|
-
forceMore,
|
|
169
|
-
isInitial,
|
|
170
|
-
});
|
|
171
|
-
const nodeKey = String(node.id);
|
|
172
|
-
const nodeUpdates = new Map<string, Partial<GraphNode>>();
|
|
134
|
+
const nodeUpdates = new Map<string | number, Partial<GraphNode>>();
|
|
173
135
|
const maybeAutoExpandMore = (neighborCount: number) => {
|
|
174
136
|
if (forceMore) return;
|
|
175
137
|
if (neighborCount > 3) return;
|
|
176
|
-
if (autoExpandMoreDoneRef.current.has(
|
|
177
|
-
autoExpandMoreDoneRef.current.add(
|
|
138
|
+
if (autoExpandMoreDoneRef.current.has(String(node.id))) return;
|
|
139
|
+
autoExpandMoreDoneRef.current.add(String(node.id));
|
|
178
140
|
setTimeout(() => {
|
|
179
|
-
if (
|
|
141
|
+
if (selectedNodeRef.current?.id !== node.id) return;
|
|
180
142
|
|
|
181
143
|
fetchAndExpandNode(node, false, true);
|
|
182
144
|
}, 900);
|
|
183
145
|
};
|
|
184
146
|
|
|
185
|
-
const getLinkIdEarly = (thing: any) => {
|
|
186
|
-
if (typeof thing === 'object' && thing !== null) return String((thing as any).id);
|
|
187
|
-
return String(thing);
|
|
188
|
-
};
|
|
189
|
-
/** Must read links after awaits (cache fetch); stale `currentLinks` from expand start misses edges and falsely skips duplicate detection. */
|
|
190
|
-
const edgeExistsBetweenFresh = (a: string, b: string) => {
|
|
191
|
-
const links = graphDataRef.current.links;
|
|
192
|
-
return links.some((l) => {
|
|
193
|
-
const s = getLinkIdEarly(l.source);
|
|
194
|
-
const t = getLinkIdEarly(l.target);
|
|
195
|
-
return (s === a && t === b) || (s === b && t === a);
|
|
196
|
-
});
|
|
197
|
-
};
|
|
198
|
-
|
|
199
147
|
if (cacheEnabled && !forceMore) {
|
|
200
148
|
const cacheHit = await fetchCacheExpansion(node.id);
|
|
201
|
-
const cacheCount = cacheHit?.nodes?.length ?? 0;
|
|
202
|
-
console.info("[Expansion] cache GET", {
|
|
203
|
-
title: node.title,
|
|
204
|
-
hit: cacheHit?.hit,
|
|
205
|
-
neighbors: cacheCount,
|
|
206
|
-
});
|
|
207
149
|
if (cacheHit && cacheHit.hit === "exact" && cacheHit.nodes) {
|
|
208
150
|
let validCached: any[] = cacheHit.nodes.filter((cn: any) => String(cn.id) !== String(node.id));
|
|
209
151
|
// Concurrent upgrade of Wikipedia summaries if needed
|
|
@@ -214,11 +156,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
214
156
|
...prev,
|
|
215
157
|
nodes: prev.nodes.map(n => String(n.id) === String(cn.id) ? { ...n, wikiChecked: true } : n)
|
|
216
158
|
}));
|
|
217
|
-
const wiki = await
|
|
218
|
-
fetchWikipediaSummary(cn.title),
|
|
219
|
-
WIKI_SUMMARY_TIMEOUT_MS,
|
|
220
|
-
'Wikipedia summary timeout',
|
|
221
|
-
).catch(() => ({ extract: null, pageid: null, title: null } as const));
|
|
159
|
+
const wiki = await fetchWikipediaSummary(cn.title);
|
|
222
160
|
if (!wiki.extract && !wiki.pageid) return cn; // Return original if no new wiki data
|
|
223
161
|
setGraphData(prev => ({
|
|
224
162
|
...prev,
|
|
@@ -246,33 +184,13 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
246
184
|
const expectedChildIsAtomic = !parentIsAtomic;
|
|
247
185
|
validCached = upgraded.map((cn: any) => ({ ...cn, is_atomic: expectedChildIsAtomic }));
|
|
248
186
|
|
|
249
|
-
|
|
250
|
-
if (validCached.length >= 1) {
|
|
187
|
+
if (validCached.length >= 5) {
|
|
251
188
|
const existingNodeIdsBefore = new Set(graphDataRef.current.nodes.map(n => String(n.id)));
|
|
252
|
-
const parentIdStr = nodeKey;
|
|
253
|
-
|
|
254
|
-
/** If DB only echoes edges already drawn (typical leaf actor → one film that's on screen), a cache-only merge adds no new bubbles — skip to AI so expansion actually shows filmography nodes. */
|
|
255
|
-
const cacheDuplicatesVisibleGraph =
|
|
256
|
-
validCached.every(
|
|
257
|
-
(cn: any) =>
|
|
258
|
-
existingNodeIdsBefore.has(String(cn.id)) &&
|
|
259
|
-
edgeExistsBetweenFresh(parentIdStr, String(cn.id)),
|
|
260
|
-
);
|
|
261
|
-
|
|
262
|
-
if (cacheDuplicatesVisibleGraph) {
|
|
263
|
-
console.info("[Expansion] exact cache overlaps graph — skipping shortcut, fetching AI expansion", {
|
|
264
|
-
title: node.title,
|
|
265
|
-
neighborsInCache: validCached.length,
|
|
266
|
-
});
|
|
267
|
-
} else {
|
|
268
189
|
const newChildIds: (string | number)[] = validCached.filter(cn => !existingNodeIdsBefore.has(String(cn.id))).map(cn => cn.id);
|
|
269
190
|
// Include ALL connected nodes for highlighting, not just new ones
|
|
270
191
|
const allConnectedNodeIds = validCached.map(cn => cn.id);
|
|
271
192
|
|
|
272
|
-
if (isStale())
|
|
273
|
-
clearThisNodeLoading();
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
193
|
+
if (isStale()) return;
|
|
276
194
|
|
|
277
195
|
setGraphData(prev => mergeExpansionGraph({
|
|
278
196
|
nodes: prev.nodes,
|
|
@@ -293,16 +211,17 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
293
211
|
|
|
294
212
|
validCached.forEach((cn, idx) => {
|
|
295
213
|
if (!cn.imageUrl && !cn.imageChecked && !isTextOnly) {
|
|
296
|
-
setTimeout(() => loadNodeImage(cn.id, cn.title),
|
|
214
|
+
setTimeout(() => loadNodeImage(cn.id, cn.title), 200 + 220 * idx);
|
|
297
215
|
}
|
|
298
216
|
});
|
|
299
217
|
|
|
218
|
+
|
|
219
|
+
setIsProcessing(false);
|
|
300
220
|
setGraphData(prev => ({
|
|
301
221
|
...prev,
|
|
302
|
-
nodes: prev.nodes.map(n =>
|
|
222
|
+
nodes: prev.nodes.map(n => n.id === node.id ? { ...n, expanded: true, isLoading: false } : n)
|
|
303
223
|
}));
|
|
304
224
|
return;
|
|
305
|
-
}
|
|
306
225
|
}
|
|
307
226
|
}
|
|
308
227
|
}
|
|
@@ -318,27 +237,12 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
318
237
|
getLinkId(l.target) === String(node.id)
|
|
319
238
|
);
|
|
320
239
|
|
|
321
|
-
const
|
|
240
|
+
const neighborNames = neighborLinks.map(l => {
|
|
322
241
|
const sid = getLinkId(l.source);
|
|
323
242
|
const tid = getLinkId(l.target);
|
|
324
243
|
const neighborId = sid === String(node.id) ? tid : sid;
|
|
325
|
-
return currentNodes.find(n => String(n.id) === String(neighborId));
|
|
326
|
-
}).filter(
|
|
327
|
-
|
|
328
|
-
const neighborNames = neighborNodes.map(n => n.title || '').filter(Boolean);
|
|
329
|
-
|
|
330
|
-
/** Only composite-side titles (films, orgs, works). Do not pass fellow cast members — the works prompt asks for NEW films, and listing other actors as "excludes" often yields an empty model response. */
|
|
331
|
-
const worksExcludeTitles = neighborNodes
|
|
332
|
-
.filter((n) => {
|
|
333
|
-
if (n.is_atomic === true || (n as any).is_person === true) return false;
|
|
334
|
-
if (n.is_atomic === false) return true;
|
|
335
|
-
const t = (n.type || '').toLowerCase();
|
|
336
|
-
if (/\b(actor|person|author|character|composer|scientist|philosopher)\b/.test(t)) return false;
|
|
337
|
-
if (/\b(movie|film|novel|book|album|series|event|organization|museum|institution|battle|war|movement)\b/.test(t)) return true;
|
|
338
|
-
return false;
|
|
339
|
-
})
|
|
340
|
-
.map(n => n.title || '')
|
|
341
|
-
.filter(Boolean);
|
|
244
|
+
return currentNodes.find(n => String(n.id) === String(neighborId))?.title || '';
|
|
245
|
+
}).filter(Boolean);
|
|
342
246
|
|
|
343
247
|
|
|
344
248
|
let wiki: any = {
|
|
@@ -347,16 +251,12 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
347
251
|
mentioningPageTitles: node.mentioningPageTitles || null
|
|
348
252
|
};
|
|
349
253
|
if ((!wiki.extract && !wiki.pageid) || (wiki.extract && !wiki.pageid && !wiki.mentioningPageTitles)) {
|
|
350
|
-
wiki = await
|
|
351
|
-
fetchWikipediaSummary(node.title, neighborNames.join(' ')),
|
|
352
|
-
WIKI_SUMMARY_TIMEOUT_MS,
|
|
353
|
-
'Wikipedia summary timeout',
|
|
354
|
-
).catch(() => ({ extract: null, pageid: null, title: null, mentioningPageTitles: null }));
|
|
254
|
+
wiki = await fetchWikipediaSummary(node.title, neighborNames.join(' '));
|
|
355
255
|
}
|
|
356
256
|
|
|
357
257
|
if (wiki.extract) {
|
|
358
258
|
const isPerson = node.is_atomic === true || node.is_person === true || node.type?.toLowerCase() === 'person';
|
|
359
|
-
nodeUpdates.set(
|
|
259
|
+
nodeUpdates.set(node.id, {
|
|
360
260
|
wikiSummary: wiki.extract,
|
|
361
261
|
wikipedia_id: wiki.pageid?.toString(),
|
|
362
262
|
mentioningPageTitles: wiki.mentioningPageTitles || undefined,
|
|
@@ -374,7 +274,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
374
274
|
const isAcademicPair = ENABLE_ACADEMIC_CORPORA && (pair.atomicType.toLowerCase() === 'author' || pair.compositeType.toLowerCase() === 'paper');
|
|
375
275
|
|
|
376
276
|
if (!node.classification_reasoning) {
|
|
377
|
-
nodeUpdates.set(
|
|
277
|
+
nodeUpdates.set(node.id, {
|
|
378
278
|
classification_reasoning: `Locked pair: ${pair.atomicType} ↔ ${pair.compositeType}.`,
|
|
379
279
|
atomic_type: pair.atomicType,
|
|
380
280
|
composite_type: pair.compositeType
|
|
@@ -390,11 +290,11 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
390
290
|
|
|
391
291
|
if (typeof inferred === 'boolean') {
|
|
392
292
|
currentIsAtomic = inferred;
|
|
393
|
-
nodeUpdates.set(
|
|
293
|
+
nodeUpdates.set(node.id, { is_atomic: inferred });
|
|
394
294
|
} else {
|
|
395
295
|
const classification = await classifyEntity(node.title);
|
|
396
296
|
currentIsAtomic = classification.isAtomic;
|
|
397
|
-
nodeUpdates.set(
|
|
297
|
+
nodeUpdates.set(node.id, {
|
|
398
298
|
...(typeof (node.is_atomic ?? (node as any).is_person) === 'boolean' ? {} : { is_atomic: classification.isAtomic }),
|
|
399
299
|
type: classification.type
|
|
400
300
|
});
|
|
@@ -446,7 +346,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
446
346
|
edge_label: 'Authored',
|
|
447
347
|
edge_meta: { evidence: makeOpenAlexAuthorshipEvidence(w, node.title) }
|
|
448
348
|
}));
|
|
449
|
-
if (!meta.openAlexAuthorId && author.id) nodeUpdates.set(
|
|
349
|
+
if (!meta.openAlexAuthorId && author.id) nodeUpdates.set(node.id, { meta: { ...meta, openAlexAuthorId: author.id, openAlexUrl: author.id, source: 'openalex' } });
|
|
450
350
|
}
|
|
451
351
|
} else {
|
|
452
352
|
// Check if this is "Work (Author)" pattern - if so, skip OpenAlex (it returns modern editions/translators)
|
|
@@ -466,7 +366,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
466
366
|
}));
|
|
467
367
|
if (!meta.openAlexWorkId && work.id) {
|
|
468
368
|
const paperNode = openAlexWorkToPaperNode(work);
|
|
469
|
-
nodeUpdates.set(
|
|
369
|
+
nodeUpdates.set(node.id, {
|
|
470
370
|
meta: { ...meta, openAlexWorkId: work.id, doi: work.doi || undefined, openAlexUrl: work.id, source: 'openalex' },
|
|
471
371
|
...((node.description || '').trim() ? {} : { description: paperNode.description, year: paperNode.year })
|
|
472
372
|
});
|
|
@@ -483,7 +383,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
483
383
|
edge_meta: { evidence: makeCrossrefAuthorshipEvidence(cw, name) }
|
|
484
384
|
}));
|
|
485
385
|
const paperNode = crossrefWorkToPaperNode(cw);
|
|
486
|
-
nodeUpdates.set(
|
|
386
|
+
nodeUpdates.set(node.id, {
|
|
487
387
|
meta: { ...meta, doi: cw.DOI || doi, crossrefUrl: paperNode.meta?.crossrefUrl, source: 'crossref' },
|
|
488
388
|
...((node.description || '').trim() ? {} : { description: paperNode.description, year: paperNode.year })
|
|
489
389
|
});
|
|
@@ -496,23 +396,10 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
496
396
|
// Fallback: If academic results were empty, proceed to standard expansion
|
|
497
397
|
if (results.length === 0) {
|
|
498
398
|
if (isPerson) {
|
|
499
|
-
let data = await fetchPersonWorks(node.title,
|
|
500
|
-
if ((!data.works || data.works.length === 0) &&
|
|
399
|
+
let data = await fetchPersonWorks(node.title, neighborNames, verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
|
|
400
|
+
if ((!data.works || data.works.length === 0) && neighborNames.length > 0) {
|
|
501
401
|
data = await fetchPersonWorks(node.title, [], verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
|
|
502
402
|
}
|
|
503
|
-
console.info("[Expansion] works raw", {
|
|
504
|
-
title: node.title,
|
|
505
|
-
worksExcludeTitles: worksExcludeTitles.length,
|
|
506
|
-
works: (data as any)?.works?.length ?? 0,
|
|
507
|
-
sample: clipForLlmLog(
|
|
508
|
-
JSON.stringify(((data as any)?.works || []).slice(0, 4).map((w: any) => ({
|
|
509
|
-
entity: w.entity,
|
|
510
|
-
wikipediaTitle: w.wikipediaTitle,
|
|
511
|
-
type: w.type,
|
|
512
|
-
year: w.year,
|
|
513
|
-
})))
|
|
514
|
-
),
|
|
515
|
-
});
|
|
516
403
|
results = (data.works || []).filter(w => typeof (w as any).entity === 'string' && (w as any).entity.trim().length > 0).map(w => ({
|
|
517
404
|
title: (w as any).wikipediaTitle || w.entity,
|
|
518
405
|
type: (w as any).type || currentCompositeType,
|
|
@@ -537,7 +424,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
537
424
|
if ((!data.people || data.people.length === 0) && neighborNames.length > 0) {
|
|
538
425
|
data = await fetchConnections(node.title, undefined, [], verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
|
|
539
426
|
}
|
|
540
|
-
if (data.sourceYear) nodeUpdates.set(
|
|
427
|
+
if (data.sourceYear) nodeUpdates.set(node.id, { year: data.sourceYear });
|
|
541
428
|
const atomicTypeToUse = currentAtomicType || 'Person';
|
|
542
429
|
results = (data.people || []).map(p => ({
|
|
543
430
|
title: (p as any).wikipediaTitle || p.name,
|
|
@@ -614,14 +501,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
614
501
|
setExpandingNodeId(null);
|
|
615
502
|
setNewChildNodeIds(new Set());
|
|
616
503
|
} else {
|
|
617
|
-
|
|
618
|
-
message:
|
|
619
|
-
cacheEnabled
|
|
620
|
-
? `No new connections for "${node.title}". Often the AI returned none or only links you already had. If failures repeat, check the cache terminal for quota/API errors.`
|
|
621
|
-
: `No new connections for "${node.title}". The model returned nothing usable—check API keys in .env.local.`,
|
|
622
|
-
type: 'error',
|
|
623
|
-
});
|
|
624
|
-
setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => String(n.id) === String(node.id) ? { ...n, expanded: false, isLoading: false } : n) }));
|
|
504
|
+
setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => String(n.id) === String(node.id) ? { ...n, expanded: true, isLoading: false } : n) }));
|
|
625
505
|
setExpandingNodeId(null);
|
|
626
506
|
setNewChildNodeIds(new Set());
|
|
627
507
|
}
|
|
@@ -629,13 +509,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
629
509
|
const resultsWithWiki = await Promise.all(results.map(async r => {
|
|
630
510
|
const contextHint = [node.title, r.type, r.edge_label || r.role, r.description, r.edge_meta?.evidence?.snippet].filter(Boolean).join(' · ').slice(0, 280);
|
|
631
511
|
const skipWiki = isAcademicPair || String(r.edge_meta?.evidence?.kind || '') === 'openalex';
|
|
632
|
-
const rWiki = skipWiki
|
|
633
|
-
? ({ title: r.title, extract: '', pageid: undefined } as any)
|
|
634
|
-
: await withTimeout(
|
|
635
|
-
fetchWikipediaSummary(r.title, contextHint),
|
|
636
|
-
WIKI_SUMMARY_TIMEOUT_MS,
|
|
637
|
-
'Wikipedia summary timeout',
|
|
638
|
-
).catch(() => ({ title: r.title, extract: '', pageid: undefined } as any));
|
|
512
|
+
const rWiki = skipWiki ? ({ title: r.title, extract: '', pageid: undefined } as any) : await fetchWikipediaSummary(r.title, contextHint);
|
|
639
513
|
let evidence: any = r.edge_meta?.evidence || { kind: 'none' as const };
|
|
640
514
|
const pageTitle = String(evidence?.pageTitle || '');
|
|
641
515
|
const snippet = String(evidence?.snippet || '');
|
|
@@ -663,12 +537,6 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
663
537
|
|
|
664
538
|
let nodesToUse = resultsWithWiki;
|
|
665
539
|
if (!exploreTerm.toLowerCase().startsWith('list of ')) nodesToUse = nodesToUse.filter((n: any) => !isBadListPage(n.title));
|
|
666
|
-
console.info("[Expansion] post-wiki", {
|
|
667
|
-
title: node.title,
|
|
668
|
-
results: results.length,
|
|
669
|
-
resultsWithWiki: resultsWithWiki.length,
|
|
670
|
-
nodesToUseAfterListFilter: nodesToUse.length,
|
|
671
|
-
});
|
|
672
540
|
|
|
673
541
|
let finalIDMap: Record<string, number> | undefined;
|
|
674
542
|
if (cacheEnabled) {
|
|
@@ -686,11 +554,9 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
686
554
|
});
|
|
687
555
|
combinedNodes = Array.from(byTitle.values());
|
|
688
556
|
}
|
|
689
|
-
finalIDMap = await saveCacheExpansion(node.id, combinedNodes);
|
|
557
|
+
finalIDMap = (await saveCacheExpansion(node.id, combinedNodes)) ?? undefined;
|
|
690
558
|
const cacheHit = await fetchCacheExpansion(node.id);
|
|
691
|
-
if (cacheHit &&
|
|
692
|
-
nodesToUse = cacheHit.nodes;
|
|
693
|
-
}
|
|
559
|
+
if (cacheHit && cacheHit.nodes) nodesToUse = cacheHit.nodes;
|
|
694
560
|
}
|
|
695
561
|
|
|
696
562
|
const currentNodesForDedupe = graphDataRef.current.nodes;
|
|
@@ -705,11 +571,6 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
705
571
|
}
|
|
706
572
|
return true; // Allow all non-person nodes
|
|
707
573
|
});
|
|
708
|
-
console.info("[Expansion] nodesToUse after sanitize/type-filter", {
|
|
709
|
-
title: node.title,
|
|
710
|
-
nodesToUse: nodesToUse.length,
|
|
711
|
-
existingByNorm: existingByNorm.size,
|
|
712
|
-
});
|
|
713
574
|
|
|
714
575
|
|
|
715
576
|
const processedNodes = nodesToUse.map(cn => {
|
|
@@ -737,30 +598,11 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
737
598
|
// Include ALL connected nodes for highlighting, not just new ones
|
|
738
599
|
const allConnectedNodeIds = processedNodes.map(cn => cn.id);
|
|
739
600
|
|
|
740
|
-
if (processedNodes.length > 0 && newChildIds.length === 0) {
|
|
741
|
-
console.info("[Expansion] all processed nodes already present (by id)", {
|
|
742
|
-
title: node.title,
|
|
743
|
-
processedNodes: processedNodes.length,
|
|
744
|
-
existingNodeIdsBefore: existingNodeIdsBefore.size,
|
|
745
|
-
sampleTitles: processedNodes.slice(0, 8).map(n => n.title),
|
|
746
|
-
});
|
|
747
|
-
} else {
|
|
748
|
-
console.info("[Expansion] new child ids", {
|
|
749
|
-
title: node.title,
|
|
750
|
-
processedNodes: processedNodes.length,
|
|
751
|
-
newChildIds: newChildIds.length,
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
|
|
755
601
|
|
|
756
602
|
|
|
757
|
-
if (isStale())
|
|
758
|
-
clearThisNodeLoading();
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
603
|
+
if (isStale()) return;
|
|
761
604
|
setGraphData(prev => {
|
|
762
605
|
const nodeMap = new Map<string, GraphNode>(prev.nodes.map(n => [String(n.id), n]));
|
|
763
|
-
const existingNodeIds = new Set(prev.nodes.map(n => String(n.id)));
|
|
764
606
|
const expectedChildIsAtomic = !currentIsAtomic;
|
|
765
607
|
processedNodes.forEach(cn => {
|
|
766
608
|
const meta = cn.meta || {};
|
|
@@ -777,14 +619,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
777
619
|
expanded: existing?.expanded || false, isLoading: false
|
|
778
620
|
});
|
|
779
621
|
});
|
|
780
|
-
if (nodeMap.has(String(node.id))) {
|
|
781
|
-
nodeMap.set(String(node.id), {
|
|
782
|
-
...nodeMap.get(String(node.id))!,
|
|
783
|
-
expanded: true,
|
|
784
|
-
isLoading: false,
|
|
785
|
-
...nodeUpdates.get(nodeKey),
|
|
786
|
-
});
|
|
787
|
-
}
|
|
622
|
+
if (nodeMap.has(String(node.id))) nodeMap.set(String(node.id), { ...nodeMap.get(String(node.id))!, expanded: true, isLoading: true, ...nodeUpdates.get(node.id) });
|
|
788
623
|
|
|
789
624
|
const getLinkId = (thing: any) => String(typeof thing === 'object' ? thing?.id : thing);
|
|
790
625
|
const linkMap = new Map<string, GraphLink>(prev.links.map(l => [`${getLinkId(l.source)}↔${getLinkId(l.target)}`, l]));
|
|
@@ -836,12 +671,10 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
836
671
|
degree.set(t, (degree.get(t) || 0) + 1);
|
|
837
672
|
});
|
|
838
673
|
const finalNodes = Array.from(nodeMap.values()).filter(n => {
|
|
839
|
-
const
|
|
840
|
-
const isExisting = existingNodeIds.has(String(n.id));
|
|
674
|
+
const isExpandingRoot = String(n.id) === String(node.id);
|
|
841
675
|
const hasDegree = (degree.get(String(n.id)) || 0) > 0;
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
return ok;
|
|
676
|
+
if (n.isLoading) return true;
|
|
677
|
+
return isExpandingRoot || hasDegree;
|
|
845
678
|
});
|
|
846
679
|
|
|
847
680
|
return dedupeGraph(finalNodes, combinedLinks);
|
|
@@ -851,18 +684,19 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
851
684
|
|
|
852
685
|
// Highlight ALL connected nodes, not just new ones
|
|
853
686
|
if (!skipExpandingHighlight) setNewChildNodeIds(new Set(allConnectedNodeIds.map(id => String(id))));
|
|
854
|
-
processedNodes.forEach((cn, idx) => {
|
|
687
|
+
processedNodes.forEach((cn, idx) => {
|
|
688
|
+
if (!cn.imageUrl && !cn.imageChecked && !isTextOnly) {
|
|
689
|
+
setTimeout(() => loadNodeImage(cn.id, cn.title), 350 + 380 * idx);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
855
692
|
|
|
856
693
|
setTimeout(() => {
|
|
857
|
-
if (isStale())
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
861
|
-
setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => String(n.id) === String(node.id) ? { ...n, expanded: true, isLoading: false, ...nodeUpdates.get(nodeKey) } : n) }));
|
|
862
|
-
const updates = nodeUpdates.get(nodeKey);
|
|
694
|
+
if (isStale()) return;
|
|
695
|
+
setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => String(n.id) === String(node.id) ? { ...n, expanded: true, isLoading: false, ...nodeUpdates.get(node.id) } : n) }));
|
|
696
|
+
const updates = nodeUpdates.get(node.id);
|
|
863
697
|
if (updates) saveCacheNodeMeta(node.id, updates, node);
|
|
864
698
|
setTimeout(() => {
|
|
865
|
-
graphRef.current?.
|
|
699
|
+
graphRef.current?.fitGraphInView();
|
|
866
700
|
if (!skipExpandingHighlight) {
|
|
867
701
|
setExpandingNodeId(null);
|
|
868
702
|
// Keep newChildNodeIds so they remain highlighted
|
|
@@ -879,11 +713,9 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
879
713
|
setSelectedNode(null); setSelectedLink(null); setExpandingNodeId(null); setNewChildNodeIds(new Set());
|
|
880
714
|
} finally {
|
|
881
715
|
clearTimeout(loadingGuard);
|
|
882
|
-
|
|
883
|
-
if (expansionInflightRef.current === 0) setIsProcessing(false);
|
|
884
|
-
clearThisNodeLoading();
|
|
716
|
+
if (!isStale()) setIsProcessing(false);
|
|
885
717
|
}
|
|
886
|
-
}, [loadNodeImage, cacheEnabled, fetchCacheExpansion, saveCacheExpansion, cacheBaseUrl, saveCacheNodeMeta, setGraphData, setIsProcessing, setError, searchIdRef, lockedPairRef, nodesRef, selectedNodeRef, autoExpandMoreDoneRef, ENABLE_ACADEMIC_CORPORA, ENABLE_WEB_SEARCH, setNewlyExpandedNodeIds, setExpandingNodeId, setNewChildNodeIds, setSelectedNode, setSelectedLink, exploreTerm, isTextOnly, graphRef
|
|
718
|
+
}, [loadNodeImage, cacheEnabled, fetchCacheExpansion, saveCacheExpansion, cacheBaseUrl, saveCacheNodeMeta, setGraphData, setIsProcessing, setError, searchIdRef, lockedPairRef, nodesRef, selectedNodeRef, autoExpandMoreDoneRef, ENABLE_ACADEMIC_CORPORA, ENABLE_WEB_SEARCH, setNewlyExpandedNodeIds, setExpandingNodeId, setNewChildNodeIds, setSelectedNode, setSelectedLink, exploreTerm, isTextOnly, graphRef]);
|
|
887
719
|
|
|
888
720
|
return { fetchAndExpandNode, fetchCacheExpansion, saveCacheExpansion };
|
|
889
721
|
}
|
package/hooks/useGraphActions.ts
CHANGED