@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.
Files changed (40) hide show
  1. package/App.tsx +360 -66
  2. package/FullPageConstellations.tsx +7 -4
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +67 -30
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +229 -250
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +2 -1
  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/hooks/useExpansion.ts +85 -230
  15. package/hooks/useGraphActions.ts +1 -0
  16. package/hooks/useGraphState.ts +75 -40
  17. package/hooks/useKioskMode.ts +1 -0
  18. package/hooks/useNodeClickHandler.ts +23 -15
  19. package/hooks/useSearchHandlers.ts +60 -21
  20. package/host.ts +1 -1
  21. package/index.css +17 -3
  22. package/index.tsx +5 -3
  23. package/package.json +4 -2
  24. package/services/aiService.ts +27 -0
  25. package/services/aiUtils.ts +285 -195
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +479 -0
  29. package/services/geminiService.ts +543 -736
  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 +79 -49
  36. package/sessionHandoff.ts +26 -0
  37. package/types.ts +3 -0
  38. package/utils/evidenceUtils.ts +1 -0
  39. package/utils/graphLogicUtils.ts +1 -0
  40. package/utils/wikiUtils.ts +14 -2
@@ -1,6 +1,7 @@
1
- import React, { useCallback, useRef } from 'react';
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/geminiService';
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 fetchWithTimeout(url.toString(), {}, CACHE_GET_TIMEOUT_MS);
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 fetchWithTimeout(
92
- new URL("/expansion", cacheBaseUrl).toString(),
93
- {
94
- method: "POST",
95
- headers: { "Content-Type": "application/json" },
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
- /** Avoid infinite spinner when isStale() returns mid-flight or setTimeout never completes. */
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 => (String(n.id) === String(node.id) ? { ...n, isLoading: true } : 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 => (String(n.id) === String(node.id) ? { ...n, isLoading: true } : 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
- console.info("[Expansion] start", {
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(nodeKey)) return;
177
- autoExpandMoreDoneRef.current.add(nodeKey);
138
+ if (autoExpandMoreDoneRef.current.has(String(node.id))) return;
139
+ autoExpandMoreDoneRef.current.add(String(node.id));
178
140
  setTimeout(() => {
179
- if (String(selectedNodeRef.current?.id) !== nodeKey) return;
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 withTimeout(
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
- // Any cached neighbors are usable (old threshold >=5 skipped most DB rows and forced LLM every time).
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), 50 * idx);
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 => (String(n.id) === nodeKey ? { ...n, expanded: true, isLoading: false } : 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 neighborNodes = neighborLinks.map(l => {
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((n): n is GraphNode => !!n);
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 withTimeout(
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(nodeKey, {
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(nodeKey, {
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(nodeKey, { is_atomic: inferred });
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(nodeKey, {
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(nodeKey, { meta: { ...meta, openAlexAuthorId: author.id, openAlexUrl: author.id, source: 'openalex' } });
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(nodeKey, {
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(nodeKey, {
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, worksExcludeTitles, verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
500
- if ((!data.works || data.works.length === 0) && worksExcludeTitles.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(nodeKey, { year: data.sourceYear });
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
- setNotification?.({
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
- console.info("[Expansion] post-wiki", {
667
- title: node.title,
668
- results: results.length,
669
- resultsWithWiki: resultsWithWiki.length,
670
- nodesToUseAfterListFilter: nodesToUse.length,
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 && Array.isArray(cacheHit.nodes) && cacheHit.nodes.length > 0) {
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
- is_atomic: (existing?.is_atomic ?? (existing as any)?.is_person ?? (typeof (cn as any).is_atomic === 'boolean' ? (cn as any).is_atomic : expectedChildIsAtomic)),
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 isOriginal = String(n.id) === String(node.id);
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
- const ok = isOriginal || isExisting || hasDegree;
843
- if (!ok) { /* removed log */ }
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) => { if (!cn.imageUrl && !cn.imageChecked && !isTextOnly) setTimeout(() => loadNodeImage(cn.id, cn.title), 300 * (idx + 1)); });
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
- clearThisNodeLoading();
859
- return;
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?.centerOnNode(node.id);
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
- expansionInflightRef.current = Math.max(0, expansionInflightRef.current - 1);
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, setNotification]);
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
  }
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import React, { useCallback } from 'react';
2
3
  import { GraphNode, GraphLink } from '../types';
3
4