@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
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,
|
|
@@ -244,35 +182,27 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
244
182
|
// and vice versa.
|
|
245
183
|
const parentIsAtomic = !!(node.is_atomic ?? (node as any).is_person ?? (node.type || '').toLowerCase() === 'person');
|
|
246
184
|
const expectedChildIsAtomic = !parentIsAtomic;
|
|
185
|
+
|
|
186
|
+
// Validate cache semantic consistency: if most cached children look like the
|
|
187
|
+
// wrong bipartite type (e.g. persons cached from when this node was an Event,
|
|
188
|
+
// but now it's a Person), skip the cache so the LLM fetches fresh data.
|
|
189
|
+
const ATOMIC_TYPE_WORDS = new Set(['person', 'actor', 'author', 'director', 'artist', 'musician', 'character', 'scientist', 'philosopher', 'researcher', 'composer', 'photographer']);
|
|
190
|
+
const atomicLookingCount = upgraded.filter((cn: any) => ATOMIC_TYPE_WORDS.has((cn.type || '').toLowerCase())).length;
|
|
191
|
+
const mostlyCachedAreAtomic = upgraded.length > 0 && atomicLookingCount > upgraded.length / 2;
|
|
192
|
+
const cacheSemanticValid = mostlyCachedAreAtomic === expectedChildIsAtomic;
|
|
193
|
+
if (!cacheSemanticValid) {
|
|
194
|
+
console.warn(`[useExpansion] Cache bypassed: cached nodes are mostly ${mostlyCachedAreAtomic ? 'atomic' : 'composite'} but expected ${expectedChildIsAtomic ? 'atomic' : 'composite'} for "${node.title}"`);
|
|
195
|
+
}
|
|
196
|
+
|
|
247
197
|
validCached = upgraded.map((cn: any) => ({ ...cn, is_atomic: expectedChildIsAtomic }));
|
|
248
198
|
|
|
249
|
-
|
|
250
|
-
if (validCached.length >= 1) {
|
|
199
|
+
if (cacheSemanticValid && validCached.length >= 5) {
|
|
251
200
|
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
201
|
const newChildIds: (string | number)[] = validCached.filter(cn => !existingNodeIdsBefore.has(String(cn.id))).map(cn => cn.id);
|
|
269
202
|
// Include ALL connected nodes for highlighting, not just new ones
|
|
270
203
|
const allConnectedNodeIds = validCached.map(cn => cn.id);
|
|
271
204
|
|
|
272
|
-
if (isStale())
|
|
273
|
-
clearThisNodeLoading();
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
205
|
+
if (isStale()) return;
|
|
276
206
|
|
|
277
207
|
setGraphData(prev => mergeExpansionGraph({
|
|
278
208
|
nodes: prev.nodes,
|
|
@@ -293,16 +223,17 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
293
223
|
|
|
294
224
|
validCached.forEach((cn, idx) => {
|
|
295
225
|
if (!cn.imageUrl && !cn.imageChecked && !isTextOnly) {
|
|
296
|
-
setTimeout(() => loadNodeImage(cn.id, cn.title),
|
|
226
|
+
setTimeout(() => loadNodeImage(cn.id, cn.title), 200 + 220 * idx);
|
|
297
227
|
}
|
|
298
228
|
});
|
|
299
229
|
|
|
230
|
+
|
|
231
|
+
setIsProcessing(false);
|
|
300
232
|
setGraphData(prev => ({
|
|
301
233
|
...prev,
|
|
302
|
-
nodes: prev.nodes.map(n =>
|
|
234
|
+
nodes: prev.nodes.map(n => n.id === node.id ? { ...n, expanded: true, isLoading: false } : n)
|
|
303
235
|
}));
|
|
304
236
|
return;
|
|
305
|
-
}
|
|
306
237
|
}
|
|
307
238
|
}
|
|
308
239
|
}
|
|
@@ -318,27 +249,12 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
318
249
|
getLinkId(l.target) === String(node.id)
|
|
319
250
|
);
|
|
320
251
|
|
|
321
|
-
const
|
|
252
|
+
const neighborNames = neighborLinks.map(l => {
|
|
322
253
|
const sid = getLinkId(l.source);
|
|
323
254
|
const tid = getLinkId(l.target);
|
|
324
255
|
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);
|
|
256
|
+
return currentNodes.find(n => String(n.id) === String(neighborId))?.title || '';
|
|
257
|
+
}).filter(Boolean);
|
|
342
258
|
|
|
343
259
|
|
|
344
260
|
let wiki: any = {
|
|
@@ -347,16 +263,12 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
347
263
|
mentioningPageTitles: node.mentioningPageTitles || null
|
|
348
264
|
};
|
|
349
265
|
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 }));
|
|
266
|
+
wiki = await fetchWikipediaSummary(node.title, neighborNames.join(' '));
|
|
355
267
|
}
|
|
356
268
|
|
|
357
269
|
if (wiki.extract) {
|
|
358
270
|
const isPerson = node.is_atomic === true || node.is_person === true || node.type?.toLowerCase() === 'person';
|
|
359
|
-
nodeUpdates.set(
|
|
271
|
+
nodeUpdates.set(node.id, {
|
|
360
272
|
wikiSummary: wiki.extract,
|
|
361
273
|
wikipedia_id: wiki.pageid?.toString(),
|
|
362
274
|
mentioningPageTitles: wiki.mentioningPageTitles || undefined,
|
|
@@ -374,7 +286,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
374
286
|
const isAcademicPair = ENABLE_ACADEMIC_CORPORA && (pair.atomicType.toLowerCase() === 'author' || pair.compositeType.toLowerCase() === 'paper');
|
|
375
287
|
|
|
376
288
|
if (!node.classification_reasoning) {
|
|
377
|
-
nodeUpdates.set(
|
|
289
|
+
nodeUpdates.set(node.id, {
|
|
378
290
|
classification_reasoning: `Locked pair: ${pair.atomicType} ↔ ${pair.compositeType}.`,
|
|
379
291
|
atomic_type: pair.atomicType,
|
|
380
292
|
composite_type: pair.compositeType
|
|
@@ -390,11 +302,11 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
390
302
|
|
|
391
303
|
if (typeof inferred === 'boolean') {
|
|
392
304
|
currentIsAtomic = inferred;
|
|
393
|
-
nodeUpdates.set(
|
|
305
|
+
nodeUpdates.set(node.id, { is_atomic: inferred });
|
|
394
306
|
} else {
|
|
395
307
|
const classification = await classifyEntity(node.title);
|
|
396
308
|
currentIsAtomic = classification.isAtomic;
|
|
397
|
-
nodeUpdates.set(
|
|
309
|
+
nodeUpdates.set(node.id, {
|
|
398
310
|
...(typeof (node.is_atomic ?? (node as any).is_person) === 'boolean' ? {} : { is_atomic: classification.isAtomic }),
|
|
399
311
|
type: classification.type
|
|
400
312
|
});
|
|
@@ -446,7 +358,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
446
358
|
edge_label: 'Authored',
|
|
447
359
|
edge_meta: { evidence: makeOpenAlexAuthorshipEvidence(w, node.title) }
|
|
448
360
|
}));
|
|
449
|
-
if (!meta.openAlexAuthorId && author.id) nodeUpdates.set(
|
|
361
|
+
if (!meta.openAlexAuthorId && author.id) nodeUpdates.set(node.id, { meta: { ...meta, openAlexAuthorId: author.id, openAlexUrl: author.id, source: 'openalex' } });
|
|
450
362
|
}
|
|
451
363
|
} else {
|
|
452
364
|
// Check if this is "Work (Author)" pattern - if so, skip OpenAlex (it returns modern editions/translators)
|
|
@@ -466,7 +378,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
466
378
|
}));
|
|
467
379
|
if (!meta.openAlexWorkId && work.id) {
|
|
468
380
|
const paperNode = openAlexWorkToPaperNode(work);
|
|
469
|
-
nodeUpdates.set(
|
|
381
|
+
nodeUpdates.set(node.id, {
|
|
470
382
|
meta: { ...meta, openAlexWorkId: work.id, doi: work.doi || undefined, openAlexUrl: work.id, source: 'openalex' },
|
|
471
383
|
...((node.description || '').trim() ? {} : { description: paperNode.description, year: paperNode.year })
|
|
472
384
|
});
|
|
@@ -483,7 +395,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
483
395
|
edge_meta: { evidence: makeCrossrefAuthorshipEvidence(cw, name) }
|
|
484
396
|
}));
|
|
485
397
|
const paperNode = crossrefWorkToPaperNode(cw);
|
|
486
|
-
nodeUpdates.set(
|
|
398
|
+
nodeUpdates.set(node.id, {
|
|
487
399
|
meta: { ...meta, doi: cw.DOI || doi, crossrefUrl: paperNode.meta?.crossrefUrl, source: 'crossref' },
|
|
488
400
|
...((node.description || '').trim() ? {} : { description: paperNode.description, year: paperNode.year })
|
|
489
401
|
});
|
|
@@ -496,23 +408,10 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
496
408
|
// Fallback: If academic results were empty, proceed to standard expansion
|
|
497
409
|
if (results.length === 0) {
|
|
498
410
|
if (isPerson) {
|
|
499
|
-
let data = await fetchPersonWorks(node.title,
|
|
500
|
-
if ((!data.works || data.works.length === 0) &&
|
|
411
|
+
let data = await fetchPersonWorks(node.title, neighborNames, verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
|
|
412
|
+
if ((!data.works || data.works.length === 0) && neighborNames.length > 0) {
|
|
501
413
|
data = await fetchPersonWorks(node.title, [], verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
|
|
502
414
|
}
|
|
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
415
|
results = (data.works || []).filter(w => typeof (w as any).entity === 'string' && (w as any).entity.trim().length > 0).map(w => ({
|
|
517
416
|
title: (w as any).wikipediaTitle || w.entity,
|
|
518
417
|
type: (w as any).type || currentCompositeType,
|
|
@@ -537,7 +436,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
537
436
|
if ((!data.people || data.people.length === 0) && neighborNames.length > 0) {
|
|
538
437
|
data = await fetchConnections(node.title, undefined, [], verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
|
|
539
438
|
}
|
|
540
|
-
if (data.sourceYear) nodeUpdates.set(
|
|
439
|
+
if (data.sourceYear) nodeUpdates.set(node.id, { year: data.sourceYear });
|
|
541
440
|
const atomicTypeToUse = currentAtomicType || 'Person';
|
|
542
441
|
results = (data.people || []).map(p => ({
|
|
543
442
|
title: (p as any).wikipediaTitle || p.name,
|
|
@@ -614,14 +513,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
614
513
|
setExpandingNodeId(null);
|
|
615
514
|
setNewChildNodeIds(new Set());
|
|
616
515
|
} 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) }));
|
|
516
|
+
setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => String(n.id) === String(node.id) ? { ...n, expanded: true, isLoading: false } : n) }));
|
|
625
517
|
setExpandingNodeId(null);
|
|
626
518
|
setNewChildNodeIds(new Set());
|
|
627
519
|
}
|
|
@@ -629,13 +521,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
629
521
|
const resultsWithWiki = await Promise.all(results.map(async r => {
|
|
630
522
|
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
523
|
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));
|
|
524
|
+
const rWiki = skipWiki ? ({ title: r.title, extract: '', pageid: undefined } as any) : await fetchWikipediaSummary(r.title, contextHint);
|
|
639
525
|
let evidence: any = r.edge_meta?.evidence || { kind: 'none' as const };
|
|
640
526
|
const pageTitle = String(evidence?.pageTitle || '');
|
|
641
527
|
const snippet = String(evidence?.snippet || '');
|
|
@@ -663,12 +549,13 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
663
549
|
|
|
664
550
|
let nodesToUse = resultsWithWiki;
|
|
665
551
|
if (!exploreTerm.toLowerCase().startsWith('list of ')) nodesToUse = nodesToUse.filter((n: any) => !isBadListPage(n.title));
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
resultsWithWiki
|
|
670
|
-
|
|
671
|
-
|
|
552
|
+
|
|
553
|
+
// Preserve LLM-assigned is_atomic before cache fetch may overwrite with stale DB values.
|
|
554
|
+
const freshAtomicByTitle = new Map<string, boolean>(
|
|
555
|
+
resultsWithWiki
|
|
556
|
+
.filter((cn: any) => typeof cn.is_atomic === 'boolean')
|
|
557
|
+
.map((cn: any) => [String(cn.title || '').toLowerCase(), Boolean(cn.is_atomic)])
|
|
558
|
+
);
|
|
672
559
|
|
|
673
560
|
let finalIDMap: Record<string, number> | undefined;
|
|
674
561
|
if (cacheEnabled) {
|
|
@@ -686,11 +573,9 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
686
573
|
});
|
|
687
574
|
combinedNodes = Array.from(byTitle.values());
|
|
688
575
|
}
|
|
689
|
-
finalIDMap = await saveCacheExpansion(node.id, combinedNodes);
|
|
576
|
+
finalIDMap = (await saveCacheExpansion(node.id, combinedNodes)) ?? undefined;
|
|
690
577
|
const cacheHit = await fetchCacheExpansion(node.id);
|
|
691
|
-
if (cacheHit &&
|
|
692
|
-
nodesToUse = cacheHit.nodes;
|
|
693
|
-
}
|
|
578
|
+
if (cacheHit && cacheHit.nodes) nodesToUse = cacheHit.nodes;
|
|
694
579
|
}
|
|
695
580
|
|
|
696
581
|
const currentNodesForDedupe = graphDataRef.current.nodes;
|
|
@@ -705,11 +590,6 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
705
590
|
}
|
|
706
591
|
return true; // Allow all non-person nodes
|
|
707
592
|
});
|
|
708
|
-
console.info("[Expansion] nodesToUse after sanitize/type-filter", {
|
|
709
|
-
title: node.title,
|
|
710
|
-
nodesToUse: nodesToUse.length,
|
|
711
|
-
existingByNorm: existingByNorm.size,
|
|
712
|
-
});
|
|
713
593
|
|
|
714
594
|
|
|
715
595
|
const processedNodes = nodesToUse.map(cn => {
|
|
@@ -737,37 +617,22 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
737
617
|
// Include ALL connected nodes for highlighting, not just new ones
|
|
738
618
|
const allConnectedNodeIds = processedNodes.map(cn => cn.id);
|
|
739
619
|
|
|
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
620
|
|
|
756
621
|
|
|
757
|
-
if (isStale())
|
|
758
|
-
clearThisNodeLoading();
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
622
|
+
if (isStale()) return;
|
|
761
623
|
setGraphData(prev => {
|
|
762
624
|
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
625
|
const expectedChildIsAtomic = !currentIsAtomic;
|
|
765
626
|
processedNodes.forEach(cn => {
|
|
766
627
|
const meta = cn.meta || {};
|
|
767
628
|
const existing = nodeMap.get(String(cn.id));
|
|
768
629
|
nodeMap.set(String(cn.id), {
|
|
769
630
|
id: cn.id, title: cn.title, type: cn.type,
|
|
770
|
-
|
|
631
|
+
// Prefer LLM-assigned is_atomic (most accurate); fall back to bipartite inference.
|
|
632
|
+
// DB is_atomic is unreliable (can be stale from a prior wrong classification).
|
|
633
|
+
is_atomic: freshAtomicByTitle.has(String(cn.title || '').toLowerCase())
|
|
634
|
+
? freshAtomicByTitle.get(String(cn.title || '').toLowerCase())!
|
|
635
|
+
: expectedChildIsAtomic,
|
|
771
636
|
wikipedia_id: cn.wikipedia_id, description: cn.description || existing?.description || "",
|
|
772
637
|
year: cn.year ?? existing?.year, imageUrl: meta.imageUrl ?? existing?.imageUrl,
|
|
773
638
|
imageChecked: !!(meta.imageUrl ?? existing?.imageUrl) || existing?.imageChecked,
|
|
@@ -777,14 +642,7 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
777
642
|
expanded: existing?.expanded || false, isLoading: false
|
|
778
643
|
});
|
|
779
644
|
});
|
|
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
|
-
}
|
|
645
|
+
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
646
|
|
|
789
647
|
const getLinkId = (thing: any) => String(typeof thing === 'object' ? thing?.id : thing);
|
|
790
648
|
const linkMap = new Map<string, GraphLink>(prev.links.map(l => [`${getLinkId(l.source)}↔${getLinkId(l.target)}`, l]));
|
|
@@ -836,12 +694,10 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
836
694
|
degree.set(t, (degree.get(t) || 0) + 1);
|
|
837
695
|
});
|
|
838
696
|
const finalNodes = Array.from(nodeMap.values()).filter(n => {
|
|
839
|
-
const
|
|
840
|
-
const isExisting = existingNodeIds.has(String(n.id));
|
|
697
|
+
const isExpandingRoot = String(n.id) === String(node.id);
|
|
841
698
|
const hasDegree = (degree.get(String(n.id)) || 0) > 0;
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
return ok;
|
|
699
|
+
if (n.isLoading) return true;
|
|
700
|
+
return isExpandingRoot || hasDegree;
|
|
845
701
|
});
|
|
846
702
|
|
|
847
703
|
return dedupeGraph(finalNodes, combinedLinks);
|
|
@@ -851,18 +707,19 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
851
707
|
|
|
852
708
|
// Highlight ALL connected nodes, not just new ones
|
|
853
709
|
if (!skipExpandingHighlight) setNewChildNodeIds(new Set(allConnectedNodeIds.map(id => String(id))));
|
|
854
|
-
processedNodes.forEach((cn, idx) => {
|
|
710
|
+
processedNodes.forEach((cn, idx) => {
|
|
711
|
+
if (!cn.imageUrl && !cn.imageChecked && !isTextOnly) {
|
|
712
|
+
setTimeout(() => loadNodeImage(cn.id, cn.title), 350 + 380 * idx);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
855
715
|
|
|
856
716
|
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);
|
|
717
|
+
if (isStale()) return;
|
|
718
|
+
setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => String(n.id) === String(node.id) ? { ...n, expanded: true, isLoading: false, ...nodeUpdates.get(node.id) } : n) }));
|
|
719
|
+
const updates = nodeUpdates.get(node.id);
|
|
863
720
|
if (updates) saveCacheNodeMeta(node.id, updates, node);
|
|
864
721
|
setTimeout(() => {
|
|
865
|
-
graphRef.current?.
|
|
722
|
+
graphRef.current?.fitGraphInView();
|
|
866
723
|
if (!skipExpandingHighlight) {
|
|
867
724
|
setExpandingNodeId(null);
|
|
868
725
|
// Keep newChildNodeIds so they remain highlighted
|
|
@@ -879,11 +736,9 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
879
736
|
setSelectedNode(null); setSelectedLink(null); setExpandingNodeId(null); setNewChildNodeIds(new Set());
|
|
880
737
|
} finally {
|
|
881
738
|
clearTimeout(loadingGuard);
|
|
882
|
-
|
|
883
|
-
if (expansionInflightRef.current === 0) setIsProcessing(false);
|
|
884
|
-
clearThisNodeLoading();
|
|
739
|
+
if (!isStale()) setIsProcessing(false);
|
|
885
740
|
}
|
|
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
|
|
741
|
+
}, [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
742
|
|
|
888
743
|
return { fetchAndExpandNode, fetchCacheExpansion, saveCacheExpansion };
|
|
889
744
|
}
|
package/hooks/useGraphActions.ts
CHANGED