@johndimm/constellations 1.0.2 → 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 CHANGED
@@ -669,6 +669,18 @@ const App: React.FC<AppProps> = ({
669
669
  selectedKioskDomainId={selectedKioskDomainId}
670
670
  onSelectKioskDomain={(id) => { setSelectedKioskDomainId(id); setPathStart(''); setPathEnd(''); }}
671
671
  onUpdateKioskDomains={setKioskDomains}
672
+ onClear={handleClear}
673
+ onClearCache={cacheEnabled ? handleClearCache : undefined}
674
+ onExpandAllLeafNodes={handleExpandAllLeafNodes}
675
+ onSave={handleSaveGraph}
676
+ onLoad={(name) => handleLoadGraph(name, applyGraphData)}
677
+ onDeleteGraph={handleDeleteGraph}
678
+ onImport={(data) => handleImport(data, applyGraphData)}
679
+ savedGraphs={savedGraphs}
680
+ helpHover={helpHover}
681
+ onHelpHoverChange={setHelpHover}
682
+ onToggleHelp={() => setShowHelp(!showHelp)}
683
+ showHelp={showHelp}
672
684
  isProcessing={isProcessing}
673
685
  isCompact={isCompact}
674
686
  onToggleCompact={() => setIsCompact(!isCompact)}
@@ -37,8 +37,6 @@ const AppHeader: React.FC<AppHeaderProps> = ({
37
37
  }) => {
38
38
  if (!showHeader) return null;
39
39
 
40
- const showLeave = closeHref || onClose;
41
-
42
40
  return (
43
41
  <header
44
42
  className={`absolute left-0 right-0 z-[200] h-14 max-h-14 min-h-14 shrink-0 pointer-events-auto bg-slate-900/95 backdrop-blur flex items-center justify-between px-2 sm:px-3 py-2 gap-2 overflow-x-hidden max-w-full ${offsetTopClass}`}
@@ -57,9 +55,19 @@ const AppHeader: React.FC<AppHeaderProps> = ({
57
55
  >
58
56
  {panelCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
59
57
  </button>
60
- <span className="text-base sm:text-lg font-bold text-red-500 whitespace-nowrap">
61
- Constellations
62
- </span>
58
+ {closeHref ? (
59
+ <a
60
+ href={closeHref}
61
+ title="Film & Music — return to hub"
62
+ className="text-base sm:text-lg font-bold text-red-500 whitespace-nowrap hover:text-red-400 transition-colors"
63
+ >
64
+ Constellations
65
+ </a>
66
+ ) : (
67
+ <span className="text-base sm:text-lg font-bold text-red-500 whitespace-nowrap">
68
+ Constellations
69
+ </span>
70
+ )}
63
71
  </div>
64
72
  <div className="flex items-center gap-2 sm:gap-3 flex-shrink-0 mr-1">
65
73
  {selectedNode && (
@@ -79,18 +87,7 @@ const AppHeader: React.FC<AppHeaderProps> = ({
79
87
  {sidebarCollapsed ? <ChevronLeft size={18} /> : <ChevronRight size={18} />}
80
88
  </button>
81
89
  )}
82
- {showLeave && closeHref && (
83
- <a
84
- href={closeHref}
85
- onClick={onClose}
86
- className="w-9 h-9 sm:w-10 sm:h-10 bg-slate-800/80 border border-slate-700 rounded-lg flex items-center justify-center text-slate-300 hover:text-white hover:border-slate-600 transition flex-shrink-0"
87
- title="Return to the main app"
88
- aria-label="Return to the main app"
89
- >
90
- <X size={20} strokeWidth={2} />
91
- </a>
92
- )}
93
- {showLeave && !closeHref && onClose && (
90
+ {!closeHref && onClose && (
94
91
  <button
95
92
  type="button"
96
93
  onClick={(e) => {
@@ -1,6 +1,8 @@
1
1
  "use client";
2
- import React, { useState, useEffect } from 'react';
3
- import { Search, Minimize2, Maximize2, Calendar, Network, X, Link as LinkIcon, ArrowRight, Type, ChevronLeft, ChevronRight, ChevronDown, Settings } from 'lucide-react';
2
+ import React, { useState, useEffect, useRef } from 'react';
3
+ import { Search, Minimize2, Maximize2, Maximize, Calendar, Network, X, Link as LinkIcon, ArrowRight, Type, ChevronLeft, ChevronRight, ChevronDown, Settings, Download, Trash2, Share2, Copy, Upload, Plus, HelpCircle, Cpu } from 'lucide-react';
4
+ import type { LlmProviderId } from '../services/aiUtils';
5
+ import { getBrowserLlmOverride, setBrowserLlmOverride, getEnvCacheUrl } from '../services/aiUtils';
4
6
  import { DEFAULT_KIOSK_DOMAINS, saveKioskDomains, saveSelectedKioskDomainId } from '../kioskDomains';
5
7
  import type { KioskDomain } from '../kioskDomains';
6
8
 
@@ -22,6 +24,18 @@ interface ControlPanelProps {
22
24
  selectedKioskDomainId?: string;
23
25
  onSelectKioskDomain?: (domainId: string) => void;
24
26
  onUpdateKioskDomains?: (domains: KioskDomain[]) => void;
27
+ onClear?: () => void;
28
+ onClearCache?: () => void;
29
+ onExpandAllLeafNodes?: () => void;
30
+ onSave?: (name: string) => void;
31
+ onLoad?: (name: string) => void;
32
+ onDeleteGraph?: (name: string) => void;
33
+ onImport?: (data: any) => void;
34
+ savedGraphs?: string[];
35
+ helpHover?: string | null;
36
+ onHelpHoverChange?: (value: string | null) => void;
37
+ onToggleHelp?: () => void;
38
+ showHelp?: boolean;
25
39
  isProcessing: boolean;
26
40
  isCompact: boolean;
27
41
  onToggleCompact: () => void;
@@ -66,6 +80,18 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
66
80
  selectedKioskDomainId,
67
81
  onSelectKioskDomain,
68
82
  onUpdateKioskDomains,
83
+ onClear,
84
+ onClearCache,
85
+ onExpandAllLeafNodes,
86
+ onSave,
87
+ onLoad,
88
+ onDeleteGraph,
89
+ onImport,
90
+ savedGraphs = [],
91
+ helpHover,
92
+ onHelpHoverChange,
93
+ onToggleHelp,
94
+ showHelp,
69
95
  isProcessing,
70
96
  isCompact,
71
97
  onToggleCompact,
@@ -88,12 +114,42 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
88
114
  const [newDomainLabel, setNewDomainLabel] = useState('');
89
115
  const [newTerm, setNewTerm] = useState('');
90
116
  const [bulkTerms, setBulkTerms] = useState('');
117
+ const [showSave, setShowSave] = useState(false);
118
+ const [showLoad, setShowLoad] = useState(false);
119
+ const [showShare, setShowShare] = useState(false);
120
+ const [saveName, setSaveName] = useState('');
121
+ const fileInputRef = useRef<HTMLInputElement>(null);
122
+ const [llmSelectValue, setLlmSelectValue] = useState<'env' | LlmProviderId>(() => getBrowserLlmOverride() ?? 'env');
91
123
 
92
124
  // Collapsible sections state - combined toggle for examples section
93
125
  const [showExamples, setShowExamples] = useState(false);
94
126
 
95
127
  const domainsImportRef = React.useRef<HTMLInputElement>(null);
96
128
 
129
+ const handleSaveSubmit = (e: React.FormEvent) => {
130
+ e.preventDefault();
131
+ if (saveName.trim() && onSave) {
132
+ onSave(saveName.trim());
133
+ setShowSave(false);
134
+ setSaveName('');
135
+ }
136
+ };
137
+
138
+ const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
139
+ const file = e.target.files?.[0];
140
+ if (!file || !onImport) return;
141
+ const reader = new FileReader();
142
+ reader.onload = (ev) => {
143
+ try {
144
+ const data = JSON.parse(ev.target?.result as string);
145
+ onImport(data);
146
+ setShowLoad(false);
147
+ } catch { alert('Invalid JSON file'); }
148
+ finally { e.target.value = ''; }
149
+ };
150
+ reader.readAsText(file);
151
+ };
152
+
97
153
  const handleSubmit = (e: React.FormEvent) => {
98
154
  e.preventDefault();
99
155
  if (searchMode === 'explore') {
@@ -183,8 +239,65 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
183
239
  </div>
184
240
  )}
185
241
 
242
+ {/* Dev docs links */}
243
+ <div className="mb-3 flex gap-3">
244
+ <a href="/doc/help.html" target="_blank" rel="noreferrer"
245
+ className="text-[10px] text-slate-500 hover:text-slate-300 transition-colors">
246
+ Help
247
+ </a>
248
+ <a href="/doc/prompt.html" target="_blank" rel="noreferrer"
249
+ className="text-[10px] text-slate-500 hover:text-slate-300 transition-colors">
250
+ Prompt
251
+ </a>
252
+ <a href="/doc/journal.html" target="_blank" rel="noreferrer"
253
+ className="text-[10px] text-slate-500 hover:text-slate-300 transition-colors">
254
+ Dev Journal
255
+ </a>
256
+ </div>
257
+
186
258
  {/* Button Groups */}
187
259
  <div className="space-y-4 mb-4">
260
+ {/* Group: File */}
261
+ {onSave && (
262
+ <div>
263
+ <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
264
+ <Download size={10} /> File
265
+ </div>
266
+ <div className="flex flex-wrap gap-2 text-xs">
267
+ <button
268
+ onClick={() => {
269
+ let defaultName = searchMode === 'explore' && exploreTerm ? exploreTerm
270
+ : searchMode === 'connect' && pathStart && pathEnd ? `${pathStart} to ${pathEnd}`
271
+ : `Graph ${new Date().toLocaleTimeString()}`;
272
+ setSaveName(defaultName);
273
+ setShowSave(!showSave);
274
+ setShowLoad(false);
275
+ setShowShare(false);
276
+ onHelpHoverChange?.(null);
277
+ }}
278
+ className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-amber-300 transition-colors ${helpHover === 'save' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
279
+ title="Save Graph"
280
+ >
281
+ SAVE
282
+ </button>
283
+ <button
284
+ onClick={() => { setShowLoad(!showLoad); setShowSave(false); setShowShare(false); }}
285
+ className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-amber-300 transition-colors ${helpHover === 'load' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
286
+ title="Load Graph"
287
+ >
288
+ LOAD
289
+ </button>
290
+ <button
291
+ onClick={() => { setShowShare(!showShare); setShowSave(false); setShowLoad(false); onHelpHoverChange?.(null); }}
292
+ className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-amber-300 transition-colors ${helpHover === 'share' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
293
+ title="Share Graph"
294
+ >
295
+ SHARE
296
+ </button>
297
+ </div>
298
+ </div>
299
+ )}
300
+
188
301
  {/* Group: View */}
189
302
  <div>
190
303
  <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
@@ -220,8 +333,199 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
220
333
  </button>
221
334
  </div>
222
335
  </div>
336
+
337
+ {/* Group: LLM */}
338
+ <div>
339
+ <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
340
+ <Cpu size={10} /> LLM
341
+ </div>
342
+ <select
343
+ value={llmSelectValue}
344
+ onChange={(e) => {
345
+ const v = e.target.value as 'env' | LlmProviderId;
346
+ setBrowserLlmOverride(v === 'env' ? null : v);
347
+ setLlmSelectValue(v);
348
+ }}
349
+ className="w-full bg-slate-900 border border-slate-600 text-slate-100 text-xs rounded-md px-2 py-2 focus:outline-none focus:ring-1 focus:ring-indigo-500"
350
+ title="Model provider for AI calls from this tab"
351
+ >
352
+ <option value="env">Default (from .env)</option>
353
+ <option value="gemini">Google Gemini</option>
354
+ <option value="openai">OpenAI</option>
355
+ <option value="deepseek">DeepSeek</option>
356
+ <option value="anthropic">Anthropic</option>
357
+ </select>
358
+ {getEnvCacheUrl() && (
359
+ <p className="text-[9px] text-slate-500 mt-1 leading-snug">
360
+ Proxied — keys live on the server. Default uses{' '}
361
+ <span className="text-slate-400">LLM_PROVIDER</span> env var.
362
+ </p>
363
+ )}
364
+ </div>
365
+
366
+ {/* Group: Actions */}
367
+ {(onClear || onClearCache || onExpandAllLeafNodes || onToggleHelp) && (
368
+ <div>
369
+ <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
370
+ <Plus size={10} /> Actions
371
+ </div>
372
+ <div className="flex flex-wrap gap-2 text-xs">
373
+ {onClear && (
374
+ <button
375
+ onClick={onClear}
376
+ className="text-slate-300 hover:text-red-300 p-1.5 rounded-md border border-slate-700 bg-slate-800/80 transition-colors"
377
+ title="Clear graph"
378
+ >
379
+ <Trash2 size={16} />
380
+ </button>
381
+ )}
382
+ {onClearCache && (
383
+ <button
384
+ onClick={onClearCache}
385
+ className="px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-orange-300 transition-colors"
386
+ title="Clear API cache (forces fresh data from LLM)"
387
+ >
388
+ CLEAR CACHE
389
+ </button>
390
+ )}
391
+ {onExpandAllLeafNodes && (
392
+ <button
393
+ onClick={onExpandAllLeafNodes}
394
+ disabled={isProcessing}
395
+ className="px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-emerald-300 inline-flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
396
+ title="Expand all leaf nodes"
397
+ >
398
+ <Maximize size={14} className="text-emerald-400" />
399
+ EXPAND ALL
400
+ </button>
401
+ )}
402
+ {onToggleHelp && (
403
+ <button
404
+ onClick={onToggleHelp}
405
+ className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-white flex items-center gap-1 transition-colors ${helpHover === 'help' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
406
+ title="Help & Info"
407
+ >
408
+ <HelpCircle size={14} /> HELP
409
+ </button>
410
+ )}
411
+ </div>
412
+ </div>
413
+ )}
223
414
  </div>
224
415
 
416
+ {/* Share Dialog */}
417
+ {showShare && onSave && (
418
+ <div className="mb-4 bg-slate-800 p-3 rounded-lg border border-slate-600 animate-in fade-in slide-in-from-top-2 duration-200">
419
+ <div className="flex justify-between items-center mb-3">
420
+ <h3 className="text-sm font-bold text-white flex items-center gap-2">
421
+ <Share2 size={14} /> Share Graph
422
+ </h3>
423
+ <button onClick={() => setShowShare(false)}><X size={14} className="text-slate-400" /></button>
424
+ </div>
425
+ <div className="grid grid-cols-3 gap-2">
426
+ <button
427
+ onClick={() => onSave('__COPY_LINK__')}
428
+ className="flex flex-col items-center justify-center gap-2 bg-slate-700 hover:bg-slate-600 text-white p-3 rounded-lg transition-colors border border-slate-600"
429
+ >
430
+ <LinkIcon size={20} className="text-orange-400" />
431
+ <span className="text-[10px] font-bold uppercase tracking-wider text-center">Copy Link</span>
432
+ </button>
433
+ <button
434
+ onClick={() => onSave('__COPY__')}
435
+ className="flex flex-col items-center justify-center gap-2 bg-slate-700 hover:bg-slate-600 text-white p-3 rounded-lg transition-colors border border-slate-600"
436
+ >
437
+ <Copy size={20} className="text-purple-400" />
438
+ <span className="text-[10px] font-bold uppercase tracking-wider text-center">Copy JSON</span>
439
+ </button>
440
+ <button
441
+ onClick={() => onSave('__EXPORT__')}
442
+ className="flex flex-col items-center justify-center gap-2 bg-slate-700 hover:bg-slate-600 text-white p-3 rounded-lg transition-colors border border-slate-600"
443
+ >
444
+ <Download size={20} className="text-indigo-400" />
445
+ <span className="text-[10px] font-bold uppercase tracking-wider text-center">Download File</span>
446
+ </button>
447
+ </div>
448
+ </div>
449
+ )}
450
+
451
+ {/* Save Dialog */}
452
+ {showSave && onSave && (
453
+ <div className="mb-4 bg-slate-800 p-3 rounded-lg border border-slate-600">
454
+ <div className="flex justify-between items-center mb-2">
455
+ <h3 className="text-sm font-bold text-white">Save Graph</h3>
456
+ <button onClick={() => setShowSave(false)}><X size={14} className="text-slate-400" /></button>
457
+ </div>
458
+ <form onSubmit={handleSaveSubmit} className="flex gap-2">
459
+ <input
460
+ type="text"
461
+ value={saveName}
462
+ onChange={(e) => setSaveName(e.target.value)}
463
+ placeholder="Graph Name..."
464
+ className="flex-1 bg-slate-900 border border-slate-700 text-white px-2 py-1 rounded text-sm focus:outline-none focus:border-indigo-500"
465
+ autoFocus
466
+ />
467
+ <button type="submit" className="bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded text-sm font-medium">
468
+ Save
469
+ </button>
470
+ <button
471
+ type="button"
472
+ onClick={() => onSave('__EXPORT__')}
473
+ className="bg-indigo-600 hover:bg-indigo-500 text-white px-2 py-1 rounded text-sm font-medium flex items-center"
474
+ title="Export as JSON"
475
+ >
476
+ <Download size={14} />
477
+ </button>
478
+ </form>
479
+ </div>
480
+ )}
481
+
482
+ {/* Load Dialog */}
483
+ {showLoad && onLoad && (
484
+ <div className="mb-4 bg-slate-800 p-3 rounded-lg border border-slate-600 max-h-60 overflow-y-auto">
485
+ <div className="flex justify-between items-center mb-2">
486
+ <h3 className="text-sm font-bold text-white">Load Graph</h3>
487
+ <div className="flex items-center gap-2">
488
+ {onImport && (
489
+ <button
490
+ onClick={() => fileInputRef.current?.click()}
491
+ className="text-slate-400 hover:text-blue-400 flex items-center gap-1 text-xs"
492
+ title="Import JSON"
493
+ >
494
+ <Upload size={14} /> Import
495
+ </button>
496
+ )}
497
+ <button onClick={() => setShowLoad(false)}><X size={14} className="text-slate-400" /></button>
498
+ </div>
499
+ </div>
500
+ <input type="file" ref={fileInputRef} onChange={handleImportFile} accept=".json" className="hidden" />
501
+ {savedGraphs.length === 0 ? (
502
+ <p className="text-slate-400 text-xs italic">No saved graphs.</p>
503
+ ) : (
504
+ <div className="space-y-1">
505
+ {savedGraphs.map(name => (
506
+ <div key={name} className="flex justify-between items-center bg-slate-900 p-2 rounded hover:bg-slate-700 group transition-colors">
507
+ <button
508
+ onClick={() => { onLoad(name); setShowLoad(false); }}
509
+ className="text-white text-sm text-left flex-1"
510
+ >
511
+ {name}
512
+ </button>
513
+ {onDeleteGraph && (
514
+ <button
515
+ onClick={(e) => { e.stopPropagation(); onDeleteGraph(name); setShowLoad(false); }}
516
+ className="text-slate-400 hover:text-red-400 transition-colors p-1.5 rounded-md hover:bg-slate-800"
517
+ title="Delete Graph"
518
+ >
519
+ <Trash2 size={14} />
520
+ </button>
521
+ )}
522
+ </div>
523
+ ))}
524
+ </div>
525
+ )}
526
+ </div>
527
+ )}
528
+
225
529
  </div>
226
530
 
227
531
  <div onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className="flex-1 min-h-0 flex flex-col overflow-hidden">
@@ -109,7 +109,7 @@ const HelpOverlay: React.FC<HelpOverlayProps> = ({
109
109
  className="flex items-center gap-2 text-slate-300 hover:text-white transition-colors"
110
110
  >
111
111
  <LinkIcon size={14} className="text-slate-500" />
112
- <span>Regeneration Prompt</span>
112
+ <span>Prompt</span>
113
113
  </a>
114
114
  <a
115
115
  href="/doc/api_queries.html"
@@ -182,9 +182,21 @@ export function useExpansion(options: UseExpansionOptions) {
182
182
  // and vice versa.
183
183
  const parentIsAtomic = !!(node.is_atomic ?? (node as any).is_person ?? (node.type || '').toLowerCase() === 'person');
184
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
+
185
197
  validCached = upgraded.map((cn: any) => ({ ...cn, is_atomic: expectedChildIsAtomic }));
186
198
 
187
- if (validCached.length >= 5) {
199
+ if (cacheSemanticValid && validCached.length >= 5) {
188
200
  const existingNodeIdsBefore = new Set(graphDataRef.current.nodes.map(n => String(n.id)));
189
201
  const newChildIds: (string | number)[] = validCached.filter(cn => !existingNodeIdsBefore.has(String(cn.id))).map(cn => cn.id);
190
202
  // Include ALL connected nodes for highlighting, not just new ones
@@ -538,6 +550,13 @@ export function useExpansion(options: UseExpansionOptions) {
538
550
  let nodesToUse = resultsWithWiki;
539
551
  if (!exploreTerm.toLowerCase().startsWith('list of ')) nodesToUse = nodesToUse.filter((n: any) => !isBadListPage(n.title));
540
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
+ );
559
+
541
560
  let finalIDMap: Record<string, number> | undefined;
542
561
  if (cacheEnabled) {
543
562
  let combinedNodes = [...resultsWithWiki];
@@ -609,7 +628,11 @@ export function useExpansion(options: UseExpansionOptions) {
609
628
  const existing = nodeMap.get(String(cn.id));
610
629
  nodeMap.set(String(cn.id), {
611
630
  id: cn.id, title: cn.title, type: cn.type,
612
- 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,
613
636
  wikipedia_id: cn.wikipedia_id, description: cn.description || existing?.description || "",
614
637
  year: cn.year ?? existing?.year, imageUrl: meta.imageUrl ?? existing?.imageUrl,
615
638
  imageChecked: !!(meta.imageUrl ?? existing?.imageUrl) || existing?.imageChecked,
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import React, { useState, useCallback, useEffect, useRef } from 'react';
3
3
  import { GraphNode, GraphLink } from '../types';
4
- import { classifyStartPair, fetchConnectionPath, LockedPair, classifyEntity, fetchConnections } from '../services/geminiService';
4
+ import { classifyStartPair, fetchConnectionPath, LockedPair, classifyEntity, fetchConnections } from '../services/aiService';
5
5
  import { fetchWikipediaSummary } from '../services/wikipediaService';
6
6
  import { dedupeGraph, normalizeForDedup } from '../services/graphUtils';
7
7
  import { clampToViewport } from '../utils/graphLogicUtils';
@@ -147,8 +147,10 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
147
147
 
148
148
  const dim = dimensionsRef.current;
149
149
  const startNode: GraphNode = {
150
+ ...nodeData,
150
151
  id: nodeData.id,
151
152
  title: canonicalTitle,
153
+ // Fresh classification always wins over stale DB values.
152
154
  type,
153
155
  is_atomic: isAtomic,
154
156
  wikipedia_id: wiki.pageid?.toString(),
@@ -161,7 +163,6 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
161
163
  atomic_type: chosenPair.atomicType,
162
164
  composite_type: chosenPair.compositeType,
163
165
  imageUrl: nodeData.imageUrl || nodeData.image_url,
164
- ...nodeData
165
166
  };
166
167
 
167
168
  setGraphData({ nodes: [startNode], links: [] });
package/index.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import ReactDOM from 'react-dom/client';
3
- import App from './App';
3
+ import App from '@johndimm/constellations/App';
4
4
  import './index.css';
5
5
 
6
6
  const rootElement = document.getElementById('root');
@@ -9,8 +9,10 @@ if (!rootElement) {
9
9
  }
10
10
 
11
11
  const root = ReactDOM.createRoot(rootElement);
12
+ const hubUrl: string = import.meta.env.VITE_HUB_URL || "http://127.0.0.1:8000";
13
+
12
14
  root.render(
13
15
  <React.StrictMode>
14
- <App />
16
+ <App closeHref={hubUrl} />
15
17
  </React.StrictMode>
16
- );
18
+ );
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@johndimm/constellations",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "main": "./index.tsx",
6
6
  "exports": {
7
- ".": "./index.tsx",
7
+ ".": "./host.ts",
8
8
  "./App": "./App.tsx",
9
9
  "./FullPageConstellations": "./FullPageConstellations.tsx",
10
10
  "./host": "./host.ts",
@@ -52,6 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@google/genai": "^1.33.0",
55
+ "@johndimm/constellations": "^1.0.2",
55
56
  "d3": "^7.9.0",
56
57
  "dotenv": "^16.4.5",
57
58
  "lucide-react": "^0.560.0"
@@ -1,23 +1,27 @@
1
1
  /**
2
- * Provider dispatcher. Set VITE_AI_PROVIDER=deepseek (or gemini) in .env.local.
3
- * Defaults to gemini when unset.
2
+ * Provider dispatcher. gemini geminiService; everything else
3
+ * deepseekService (which re-reads getLlmProvider() inside callAltLlm
4
+ * to pick the right API: deepseek / openai / anthropic).
4
5
  */
5
- import { readBundledEnv } from "./aiUtils";
6
- import * as gemini from "./geminiService";
7
- import * as deepseek from "./deepseekService";
6
+ import { getLlmProvider } from "./aiUtils";
7
+ import * as geminiSvc from "./geminiService";
8
+ import * as altSvc from "./deepseekService"; // handles deepseek, openai, anthropic
8
9
 
9
10
  export * from "./aiUtils";
10
11
  export type { LockedPair } from "./geminiService";
11
12
 
12
- const isDeepSeek = (readBundledEnv("VITE_AI_PROVIDER") || "gemini").toLowerCase() === "deepseek";
13
- const svc = isDeepSeek ? deepseek : gemini;
13
+ function getSvc(fn: string) {
14
+ const p = getLlmProvider();
15
+ console.info(`[LLM] ${p} · ${fn}`);
16
+ return p === "gemini" ? geminiSvc : altSvc;
17
+ }
14
18
 
15
- export const classifyStartPair = (...args: Parameters<typeof svc.classifyStartPair>) => svc.classifyStartPair(...args);
16
- export const classifyEntity = (...args: Parameters<typeof svc.classifyEntity>) => svc.classifyEntity(...args);
17
- export const fetchConnections = (...args: Parameters<typeof svc.fetchConnections>) => svc.fetchConnections(...args);
18
- export const fetchPersonWorks = (...args: Parameters<typeof svc.fetchPersonWorks>) => svc.fetchPersonWorks(...args);
19
- export const fetchConnectionPath = (...args: Parameters<typeof svc.fetchConnectionPath>) => svc.fetchConnectionPath(...args);
20
- export const findWikipediaTitle = (...args: Parameters<typeof svc.findWikipediaTitle>) => svc.findWikipediaTitle(...args);
19
+ export const classifyStartPair = (...args: Parameters<typeof geminiSvc.classifyStartPair>) => getSvc("classifyStartPair").classifyStartPair(...args);
20
+ export const classifyEntity = (...args: Parameters<typeof geminiSvc.classifyEntity>) => getSvc("classifyEntity").classifyEntity(...args);
21
+ export const fetchConnections = (...args: Parameters<typeof geminiSvc.fetchConnections>) => getSvc("fetchConnections").fetchConnections(...args);
22
+ export const fetchPersonWorks = (...args: Parameters<typeof geminiSvc.fetchPersonWorks>) => getSvc("fetchPersonWorks").fetchPersonWorks(...args);
23
+ export const fetchConnectionPath = (...args: Parameters<typeof geminiSvc.fetchConnectionPath>) => getSvc("fetchConnectionPath").fetchConnectionPath(...args);
24
+ export const findWikipediaTitle = (...args: Parameters<typeof geminiSvc.findWikipediaTitle>) => getSvc("findWikipediaTitle").findWikipediaTitle(...args);
21
25
  // Always uses Gemini — relies on Google Search grounding which is Gemini-specific.
22
- export const fetchOrgKeyPeopleBlockViaSearch = gemini.fetchOrgKeyPeopleBlockViaSearch;
23
- export const defaultStartPairResult = (...args: Parameters<typeof svc.defaultStartPairResult>) => svc.defaultStartPairResult(...args);
26
+ export const fetchOrgKeyPeopleBlockViaSearch = geminiSvc.fetchOrgKeyPeopleBlockViaSearch;
27
+ export const defaultStartPairResult = (...args: Parameters<typeof geminiSvc.defaultStartPairResult>) => getSvc("defaultStartPairResult").defaultStartPairResult(...args);
@@ -337,6 +337,87 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, errorMsg: string
337
337
  });
338
338
  }
339
339
 
340
+ export function clipForLlmLog(text: string, maxChars = 16000): string {
341
+ const s = String(text ?? "");
342
+ if (s.length <= maxChars) return s;
343
+ return `${s.slice(0, maxChars)}\n… [truncated ${s.length - maxChars} more chars]`;
344
+ }
345
+
346
+ export function isRateLimitError(e: any): boolean {
347
+ if (e?.error?.code === 429 || e?.code === 429) return true;
348
+ const s = String(e?.error?.status || "").toLowerCase();
349
+ if (s === "resource_exhausted") return true;
350
+ const t = [e?.message, e?.error, e?.status, e?.code, typeof e === "string" ? e : ""]
351
+ .map(x => (typeof x === "object" ? JSON.stringify(x) : String(x ?? "")))
352
+ .join(" ")
353
+ .toLowerCase();
354
+ return t.includes("429") || t.includes("resource_exhausted");
355
+ }
356
+
357
+ export type LlmProviderId = "gemini" | "deepseek" | "openai" | "anthropic";
358
+
359
+ const BROWSER_LLM_KEY = "constellations_llm_provider";
360
+
361
+ function isValidProvider(v: string): v is LlmProviderId {
362
+ return v === "gemini" || v === "deepseek" || v === "openai" || v === "anthropic";
363
+ }
364
+
365
+ export function getBrowserLlmOverride(): LlmProviderId | null {
366
+ if (typeof window === "undefined") return null;
367
+ try {
368
+ const v = window.localStorage.getItem(BROWSER_LLM_KEY)?.trim().toLowerCase() ?? "";
369
+ if (isValidProvider(v)) return v;
370
+ } catch {}
371
+ return null;
372
+ }
373
+
374
+ export function setBrowserLlmOverride(provider: LlmProviderId | null): void {
375
+ if (typeof window === "undefined") return;
376
+ try {
377
+ if (provider === null) {
378
+ window.localStorage.removeItem(BROWSER_LLM_KEY);
379
+ } else {
380
+ window.localStorage.setItem(BROWSER_LLM_KEY, provider);
381
+ }
382
+ } catch {}
383
+ }
384
+
385
+ // Server-side per-request override (Node.js module memory, set before each proxy call).
386
+ // This is intentionally simple — dev server is single-user so concurrent-request races are fine.
387
+ let _serverLlmOverride: LlmProviderId | null = null;
388
+
389
+ export function setServerLlmOverride(provider: LlmProviderId | null): void {
390
+ _serverLlmOverride = provider;
391
+ }
392
+
393
+ export function getLlmProvider(): LlmProviderId {
394
+ if (_serverLlmOverride) return _serverLlmOverride;
395
+ const browser = getBrowserLlmOverride();
396
+ if (browser) return browser;
397
+ const raw = (readBundledEnv("VITE_AI_PROVIDER") || "deepseek").trim().toLowerCase();
398
+ return isValidProvider(raw) ? raw : "deepseek";
399
+ }
400
+
401
+ /**
402
+ * Returns true if `term` is likely a person name (2–4 Title-Case words, no parens or digits,
403
+ * no leading article). Used as a sanity-check on LLM classification results and fallbacks.
404
+ * False-positives (e.g. "Star Wars") can happen, but this is only consulted when the model
405
+ * returns or falls back to isAtomic=false, so the worst case is a wrong default that the user
406
+ * can easily correct by re-searching with a disambiguated term.
407
+ */
408
+ export function looksLikePersonName(term: string): boolean {
409
+ const t = term.trim();
410
+ if (/[()[\]{}]/.test(t)) return false; // parenthetical tags → work title
411
+ if (/\d/.test(t)) return false; // digits → year / track number
412
+ const words = t.split(/\s+/);
413
+ if (words.length < 2 || words.length > 4) return false;
414
+ // Leading stopwords rule out "The Godfather", "A Star Is Born", etc.
415
+ if (/^(the|a|an|of|in|on|at|to|for|with|by|la|le|les|el|los|das|der|die)$/i.test(words[0])) return false;
416
+ // Each word: Title-Case word (≥2 chars), single initial with period, or name suffix
417
+ const nameWordRe = /^[A-Z][a-z'-]{1,}\.?$|^[A-Z]\.$|^(Jr|Sr|II|III|IV|VI|VII|VIII|IX)\.?$/;
418
+ return words.every(w => nameWordRe.test(w));
419
+ }
420
+
340
421
  // Improved retry logic with exponential backoff and jitter
341
422
  export async function withRetry<T>(fn: () => Promise<T>, attempts = 3, backoffMs = 1000): Promise<T> {
342
423
  let lastError: any;
@@ -1,20 +1,13 @@
1
1
  "use client";
2
2
  import { GeminiResponse, PersonWorksResponse, PathResponse } from "../types";
3
- import { parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, readBundledEnv } from "./aiUtils";
3
+ import { parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, readBundledEnv, getLlmProvider, looksLikePersonName } from "./aiUtils";
4
4
  import type { LockedPair } from "./geminiService";
5
5
 
6
6
  export type { LockedPair };
7
7
 
8
- const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
9
- const DEFAULT_MODEL = "deepseek-chat";
10
-
11
8
  const TIMEOUT_MS = 60000;
12
9
  const CLASSIFY_TIMEOUT_MS = 15000;
13
10
 
14
- function getDeepSeekApiKey(): string {
15
- return readBundledEnv("VITE_DEEPSEEK_API_KEY");
16
- }
17
-
18
11
  function shouldProxy(): boolean {
19
12
  if (typeof window === "undefined") return false;
20
13
  if ((window as any).__PRERENDER_INJECTED) return false;
@@ -27,24 +20,60 @@ async function callAiProxy(endpoint: string, body: any) {
27
20
  const resp = await fetch(url, {
28
21
  method: "POST",
29
22
  headers: { "Content-Type": "application/json" },
30
- body: JSON.stringify(body),
23
+ body: JSON.stringify({ ...body, llmProvider: getLlmProvider() }),
31
24
  });
32
25
  if (!resp.ok) throw new Error(`AI Proxy Error (${resp.status}): ${await resp.text()}`);
33
26
  return resp.json();
34
27
  }
35
28
 
36
- async function callDeepSeek(system: string, user: string, timeoutMs = TIMEOUT_MS): Promise<string> {
37
- const apiKey = getDeepSeekApiKey();
38
- if (!apiKey) throw new Error("No VITE_DEEPSEEK_API_KEY set");
29
+ async function callAltLlm(system: string, user: string, timeoutMs = TIMEOUT_MS): Promise<string> {
30
+ const provider = getLlmProvider();
31
+
32
+ if (provider === "anthropic") {
33
+ const key = readBundledEnv("VITE_ANTHROPIC_API_KEY");
34
+ if (!key) throw new Error("No VITE_ANTHROPIC_API_KEY set");
35
+ const model = readBundledEnv("VITE_ANTHROPIC_MODEL") || "claude-3-5-haiku-20241022";
36
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
37
+ method: "POST",
38
+ headers: {
39
+ "x-api-key": key,
40
+ "anthropic-version": "2023-06-01",
41
+ "content-type": "application/json",
42
+ },
43
+ body: JSON.stringify({
44
+ model,
45
+ max_tokens: 8192,
46
+ ...(system.trim() ? { system: system.trim() } : {}),
47
+ messages: [{ role: "user", content: `${user}\n\nReply with a single valid JSON object only. No markdown, no commentary.` }],
48
+ }),
49
+ });
50
+ if (!res.ok) {
51
+ const err = await res.text();
52
+ throw new Error(`Anthropic API error (${res.status}): ${err}`);
53
+ }
54
+ const data = await res.json();
55
+ const block = data?.content?.[0];
56
+ return block?.type === "text" ? block.text : "";
57
+ }
39
58
 
40
- const res = await fetch(DEEPSEEK_API_URL, {
59
+ // OpenAI-compatible: openai or deepseek
60
+ const isOpenAI = provider === "openai";
61
+ const baseUrl = isOpenAI
62
+ ? (readBundledEnv("VITE_OPENAI_BASE_URL") || "https://api.openai.com/v1")
63
+ : (readBundledEnv("VITE_DEEPSEEK_BASE_URL") || "https://api.deepseek.com/v1");
64
+ const model = isOpenAI
65
+ ? (readBundledEnv("VITE_OPENAI_MODEL") || "gpt-4o-mini")
66
+ : (readBundledEnv("VITE_DEEPSEEK_MODEL") || "deepseek-chat");
67
+ const key = isOpenAI
68
+ ? readBundledEnv("VITE_OPENAI_API_KEY")
69
+ : readBundledEnv("VITE_DEEPSEEK_API_KEY");
70
+ if (!key) throw new Error(`No API key set for ${provider}`);
71
+
72
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
41
73
  method: "POST",
42
- headers: {
43
- "Content-Type": "application/json",
44
- Authorization: `Bearer ${apiKey}`,
45
- },
74
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
46
75
  body: JSON.stringify({
47
- model: DEFAULT_MODEL,
76
+ model,
48
77
  messages: [
49
78
  { role: "system", content: system },
50
79
  { role: "user", content: user },
@@ -52,16 +81,26 @@ async function callDeepSeek(system: string, user: string, timeoutMs = TIMEOUT_MS
52
81
  response_format: { type: "json_object" },
53
82
  }),
54
83
  });
55
-
56
84
  if (!res.ok) {
57
85
  const err = await res.text();
58
- throw new Error(`DeepSeek API error (${res.status}): ${err}`);
86
+ throw new Error(`${provider} API error (${res.status}): ${err}`);
59
87
  }
60
-
61
88
  const data = await res.json();
62
89
  return data.choices?.[0]?.message?.content ?? "";
63
90
  }
64
91
 
92
+ export const defaultStartPairResult = (reason: string, term?: string) => {
93
+ const isPerson = term ? looksLikePersonName(term) : false;
94
+ return {
95
+ type: isPerson ? "Person" : "Event",
96
+ description: "",
97
+ isAtomic: isPerson,
98
+ atomicType: "Person",
99
+ compositeType: "Event",
100
+ reasoning: reason,
101
+ };
102
+ };
103
+
65
104
  const SYSTEM_INSTRUCTION = `
66
105
  You are a Bipartite Graph Generator.
67
106
  Your goal is to build a graph that alternates between an "Atomic" type and a "Composite" type.
@@ -132,19 +171,16 @@ export const classifyStartPair = async (
132
171
  compositeType: string;
133
172
  reasoning: string;
134
173
  }> => {
135
- const fallback = {
136
- type: "Event",
137
- description: "",
138
- isAtomic: false,
139
- atomicType: "Person",
140
- compositeType: "Event",
141
- reasoning: "Default fallback.",
142
- };
143
-
144
- if (shouldProxy()) return callAiProxy("/api/ai/classify-start", { term, wikiContext });
174
+ const fallback = defaultStartPairResult("Default fallback.", term);
145
175
 
146
- const apiKey = getDeepSeekApiKey();
147
- if (!apiKey) return fallback;
176
+ if (shouldProxy()) {
177
+ const proxyResult = await callAiProxy("/api/ai/classify-start", { term, wikiContext });
178
+ if (!proxyResult.isAtomic && looksLikePersonName(term)) {
179
+ console.warn("[classifyStartPair] proxy returned isAtomic=false for apparent person name; overriding", term);
180
+ return { ...proxyResult, isAtomic: true, type: "Person" };
181
+ }
182
+ return proxyResult;
183
+ }
148
184
 
149
185
  const prompt = `Choose the most appropriate bipartite pair for: "${term}".
150
186
 
@@ -165,7 +201,7 @@ Return JSON with exactly these fields:
165
201
 
166
202
  try {
167
203
  const raw = await withTimeout(
168
- withRetry(() => callDeepSeek(SYSTEM_INSTRUCTION, prompt), 3, 1000),
204
+ withRetry(() => callAltLlm(SYSTEM_INSTRUCTION, prompt), 3, 1000),
169
205
  CLASSIFY_TIMEOUT_MS,
170
206
  "classifyStartPair timed out"
171
207
  );
@@ -201,9 +237,6 @@ export const classifyEntity = async (
201
237
 
202
238
  if (shouldProxy()) return callAiProxy("/api/ai/classify", { term, wikiContext });
203
239
 
204
- const apiKey = getDeepSeekApiKey();
205
- if (!apiKey) return fallback;
206
-
207
240
  const wikiPrompt = wikiContext ? `\n\nUSE THIS VERIFIED INFORMATION:\n${wikiContext}\n` : "";
208
241
 
209
242
  const prompt = `Classify "${term}".${wikiPrompt}
@@ -222,7 +255,7 @@ Return JSON:
222
255
 
223
256
  try {
224
257
  const raw = await withRetry(
225
- () => withTimeout(callDeepSeek(SYSTEM_INSTRUCTION, prompt), CLASSIFY_TIMEOUT_MS, "classifyEntity timed out"),
258
+ () => withTimeout(callAltLlm(SYSTEM_INSTRUCTION, prompt), CLASSIFY_TIMEOUT_MS, "classifyEntity timed out"),
226
259
  3,
227
260
  1000
228
261
  );
@@ -256,9 +289,6 @@ export const fetchConnections = async (
256
289
  return callAiProxy("/api/ai/connections", { nodeName, context, excludeNodes, wikiContext, wikipediaId, atomicType, compositeType, mentioningPageTitles });
257
290
  }
258
291
 
259
- const apiKey = getDeepSeekApiKey();
260
- if (!apiKey) return { people: [] };
261
-
262
292
  const atomicLabel = atomicType || "ATOMIC entity";
263
293
  const compositeLabel = compositeType || "COMPOSITE entity";
264
294
  const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
@@ -299,7 +329,7 @@ Return JSON:
299
329
 
300
330
  try {
301
331
  const raw = await withRetry(
302
- () => withTimeout(callDeepSeek(SYSTEM_INSTRUCTION, prompt), TIMEOUT_MS, "fetchConnections timed out"),
332
+ () => withTimeout(callAltLlm(SYSTEM_INSTRUCTION, prompt), TIMEOUT_MS, "fetchConnections timed out"),
303
333
  4,
304
334
  1000
305
335
  );
@@ -328,9 +358,6 @@ export const fetchPersonWorks = async (
328
358
  return callAiProxy("/api/ai/works", { nodeName, excludeNodes, wikiContext, wikipediaId, atomicType, compositeType, mentioningPageTitles });
329
359
  }
330
360
 
331
- const apiKey = getDeepSeekApiKey();
332
- if (!apiKey) return { works: [] };
333
-
334
361
  const atomicLabel = atomicType || "ATOMIC entity";
335
362
  const compositeLabel = compositeType || "COMPOSITE entity";
336
363
  const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
@@ -365,7 +392,7 @@ Return JSON:
365
392
 
366
393
  try {
367
394
  const raw = await withRetry(
368
- () => withTimeout(callDeepSeek(SYSTEM_INSTRUCTION, prompt), TIMEOUT_MS, "fetchPersonWorks timed out"),
395
+ () => withTimeout(callAltLlm(SYSTEM_INSTRUCTION, prompt), TIMEOUT_MS, "fetchPersonWorks timed out"),
369
396
  4,
370
397
  1000
371
398
  );
@@ -388,9 +415,6 @@ export const fetchConnectionPath = async (
388
415
  ): Promise<PathResponse> => {
389
416
  if (shouldProxy()) return callAiProxy("/api/ai/path", { start, end, context });
390
417
 
391
- const apiKey = getDeepSeekApiKey();
392
- if (!apiKey) return { path: [], found: false };
393
-
394
418
  const wikiPrompt = (context?.startWiki || context?.endWiki)
395
419
  ? `\n\nVERIFIED INFO:\n${context?.startWiki ? `[${start}]: ${context.startWiki}\n` : ""}${context?.endWiki ? `[${end}]: ${context.endWiki}\n` : ""}`
396
420
  : "";
@@ -412,7 +436,7 @@ Return JSON:
412
436
 
413
437
  try {
414
438
  const raw = await withTimeout(
415
- callDeepSeek(SYSTEM_INSTRUCTION, prompt),
439
+ callAltLlm(SYSTEM_INSTRUCTION, prompt),
416
440
  45000,
417
441
  "fetchConnectionPath timed out"
418
442
  );
@@ -431,9 +455,6 @@ export const findWikipediaTitle = async (
431
455
  ): Promise<{ title: string; imageHint?: string } | null> => {
432
456
  if (shouldProxy()) return callAiProxy("/api/ai/title", { name, description });
433
457
 
434
- const apiKey = getDeepSeekApiKey();
435
- if (!apiKey) return null;
436
-
437
458
  const prompt = `Find the exact English Wikipedia article title for "${name}"${description ? ` described as "${description}"` : ""}.
438
459
 
439
460
  Return JSON:
@@ -444,7 +465,7 @@ Return JSON:
444
465
 
445
466
  try {
446
467
  const raw = await withTimeout(
447
- callDeepSeek("You are a Wikipedia lookup assistant. Return strict JSON only.", prompt),
468
+ callAltLlm("You are a Wikipedia lookup assistant. Return strict JSON only.", prompt),
448
469
  10000,
449
470
  "findWikipediaTitle timed out"
450
471
  );
@@ -456,12 +477,3 @@ Return JSON:
456
477
  }
457
478
  };
458
479
 
459
-
460
- export const defaultStartPairResult = (reason: string) => ({
461
- type: "Event",
462
- description: "",
463
- isAtomic: false,
464
- atomicType: "Person",
465
- compositeType: "Event",
466
- reasoning: reason,
467
- });
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { GoogleGenAI, Type } from "@google/genai";
3
3
  import { GeminiResponse, PersonWorksResponse, PathResponse } from "../types";
4
- import { getApiKey, getResponseText, cleanJson, parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, getEnvGeminiModel, getEnvGeminiModelClassify, sanitizeSearchTerm } from "./aiUtils";
4
+ import { getApiKey, getResponseText, cleanJson, parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, getEnvGeminiModel, getEnvGeminiModelClassify, sanitizeSearchTerm, looksLikePersonName, getLlmProvider } from "./aiUtils";
5
5
 
6
6
  export { getApiKey, getResponseText, cleanJson, parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, getEnvGeminiModel, getEnvGeminiModelClassify } from "./aiUtils";
7
7
 
@@ -120,7 +120,7 @@ async function callAiProxy(endpoint: string, body: any) {
120
120
  const resp = await fetch(url, {
121
121
  method: "POST",
122
122
  headers: { "Content-Type": "application/json" },
123
- body: JSON.stringify(body)
123
+ body: JSON.stringify({ ...body, llmProvider: getLlmProvider() })
124
124
  });
125
125
 
126
126
  if (resp.status === 404 && endpoint === "/api/ai/classify-start") {
@@ -154,7 +154,7 @@ function shouldProxy(): boolean {
154
154
  return !!baseUrl;
155
155
  }
156
156
 
157
- export function defaultStartPairResult(reason: string): {
157
+ export function defaultStartPairResult(reason: string, term?: string): {
158
158
  type: string;
159
159
  description: string;
160
160
  isAtomic: boolean;
@@ -162,10 +162,11 @@ export function defaultStartPairResult(reason: string): {
162
162
  compositeType: string;
163
163
  reasoning: string;
164
164
  } {
165
+ const isPerson = term ? looksLikePersonName(term) : false;
165
166
  return {
166
- type: "Event",
167
+ type: isPerson ? "Person" : "Event",
167
168
  description: "",
168
- isAtomic: false,
169
+ isAtomic: isPerson,
169
170
  atomicType: "Person",
170
171
  compositeType: "Event",
171
172
  reasoning: reason,
@@ -291,7 +292,13 @@ export const classifyStartPair = async (
291
292
  });
292
293
  }
293
294
  if (proxy) {
294
- return callAiProxy("/api/ai/classify-start", { term: rawTerm.trim(), wikiContext });
295
+ const proxyResult = await callAiProxy("/api/ai/classify-start", { term: rawTerm.trim(), wikiContext });
296
+ // Sanity check: if proxy says non-atomic but the term strongly looks like a person name, correct it.
297
+ if (!proxyResult.isAtomic && looksLikePersonName(rawTerm)) {
298
+ console.warn("[classifyStartPair] proxy returned isAtomic=false for apparent person name; overriding", rawTerm);
299
+ return { ...proxyResult, isAtomic: true, type: "Person" };
300
+ }
301
+ return proxyResult;
295
302
  }
296
303
 
297
304
  const needsMusic = rawTermNeedsMusicEntityExtract(rawTerm);
@@ -330,7 +337,7 @@ export const classifyStartPair = async (
330
337
 
331
338
 
332
339
  if (!apiKey) {
333
- return defaultStartPairResult("No API key available; defaulting to Person↔Event.");
340
+ return defaultStartPairResult("No API key available; defaulting to Person↔Event.", term);
334
341
  }
335
342
 
336
343
  const prompt = `Choose the most appropriate bipartite pair for this session based on the input: "${term}".
@@ -391,7 +398,8 @@ Rules:
391
398
  } catch (e: any) {
392
399
  console.warn("[classifyStartPair]", term, String(e?.message || e).slice(0, 200));
393
400
  return defaultStartPairResult(
394
- "Classification API unavailable (quota/rate limit or error); defaulting to Person↔Event."
401
+ "Classification API unavailable (quota/rate limit or error); defaulting to Person↔Event.",
402
+ term
395
403
  );
396
404
  }
397
405
  };
@@ -4,6 +4,20 @@ import { jsonFromResponse } from "./aiUtils";
4
4
 
5
5
  type WikiImageCacheEntry = { url: string | null; pageId?: number; pageTitle?: string; misses?: number };
6
6
 
7
+ // Session-level rate-limit gate: after any 429, block all Wikipedia/Wikidata calls for 90s.
8
+ let _wikiRateLimitedUntil = 0;
9
+ function wikiIsRateLimited() { return Date.now() < _wikiRateLimitedUntil; }
10
+ function wikiSetRateLimited() {
11
+ _wikiRateLimitedUntil = Date.now() + 90_000;
12
+ console.warn('[Wiki] 429 received — pausing all Wikipedia/Wikidata calls for 90s');
13
+ }
14
+ async function wikiFetch(url: string, init?: RequestInit): Promise<Response | null> {
15
+ if (wikiIsRateLimited()) return null;
16
+ const res = await fetch(url, init);
17
+ if (res.status === 429) { wikiSetRateLimited(); return null; }
18
+ return res;
19
+ }
20
+
7
21
  // DuckDuckGo image search fallback (posters/cover art when Wikimedia lacks a usable image).
8
22
  export const fetchDuckDuckGoPoster = async (q: string): Promise<string | null> => {
9
23
  // Respect network sandbox: if running in a browser without CORS, skip.
@@ -493,6 +507,7 @@ export const fetchWikipediaSummary = async (
493
507
  depth: number = 0,
494
508
  triedNoContext = false
495
509
  ): Promise<{ extract: string | null; pageid: number | null; title: string | null; year?: number | null; mentioningPageTitles?: string[] | null; searchContext?: string | null }> => {
510
+ if (wikiIsRateLimited()) return { extract: null, pageid: null, title: null };
496
511
  const normKey = `${query.trim().toLowerCase()}|${context || ''}`;
497
512
  if (visited.has(normKey) || depth > 2) {
498
513
  return { extract: null, pageid: null, title: null };
@@ -504,7 +519,8 @@ export const fetchWikipediaSummary = async (
504
519
  const tryDirectLookup = async (titleToFetch: string) => {
505
520
  try {
506
521
  const directUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|pageprops&exintro&explaintext&titles=${encodeURIComponent(titleToFetch)}&redirects=1&origin=*`;
507
- const directRes = await fetch(directUrl);
522
+ const directRes = await wikiFetch(directUrl);
523
+ if (!directRes) return null;
508
524
  const directData = (await jsonFromResponse(directRes)) as { query?: { pages?: unknown; redirects?: unknown } } | null;
509
525
  if (!directData) return null;
510
526
  const directPages = directData.query?.pages;
@@ -646,7 +662,8 @@ export const fetchWikipediaSummary = async (
646
662
 
647
663
  const avoidMedia = /\b(project|program|programme|operation|war|battle|campaign|treaty|scandal|scientist)\b/i.test(baseQuery);
648
664
  const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&srsearch=${encodeURIComponent(searchQuery)}&srlimit=5&origin=*`;
649
- const searchRes = await fetch(searchUrl);
665
+ const searchRes = await wikiFetch(searchUrl);
666
+ if (!searchRes) return { extract: null, pageid: null, title: null };
650
667
  const searchData = (await jsonFromResponse(searchRes)) as { query?: { search?: any[] } } | null;
651
668
 
652
669
  let bestTitle = query;
@@ -796,7 +813,8 @@ export const fetchWikipediaSummary = async (
796
813
  }
797
814
  }
798
815
  const summaryUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|pageprops&exintro&explaintext&titles=${encodeURIComponent(titleToTry)}&redirects=1&origin=*`;
799
- const summaryRes = await fetch(summaryUrl);
816
+ const summaryRes = await wikiFetch(summaryUrl);
817
+ if (!summaryRes) break;
800
818
  const summaryData = (await jsonFromResponse(summaryRes)) as { query?: { pages?: unknown } } | null;
801
819
  if (!summaryData) continue;
802
820
  const pages = summaryData.query?.pages;
@@ -1061,8 +1079,10 @@ const fetchWikidataLabels = async (ids: string[], signal: AbortSignal): Promise<
1061
1079
 
1062
1080
  const resolveWikidataIdBySearch = async (label: string, signal: AbortSignal): Promise<string | null> => {
1063
1081
  try {
1082
+ if (wikiIsRateLimited()) return null;
1064
1083
  const url = `https://www.wikidata.org/w/api.php?action=wbsearchentities&format=json&language=en&limit=8&search=${encodeURIComponent(label)}&origin=*`;
1065
1084
  const res = await fetch(url, { signal });
1085
+ if (res.status === 429) { wikiSetRateLimited(); return null; }
1066
1086
  const data = (await jsonFromResponse(res)) as { search?: any[] } | null;
1067
1087
  const results: any[] = data?.search || [];
1068
1088
  if (!results.length) return null;
package/sessionHandoff.ts CHANGED
@@ -130,3 +130,29 @@ export function takeEmbedHandoffForInitialState(): ConstellationsSessionHandoffV
130
130
  embedHandoffMem = null;
131
131
  return null;
132
132
  }
133
+
134
+ declare global {
135
+ interface Window {
136
+ __soundingsConstellationsGetHandoff?: () => unknown;
137
+ }
138
+ }
139
+
140
+ /** Serialize current embedded graph (`__soundingsConstellationsGetHandoff`) before navigating away. */
141
+ export function persistWindowConstellationsHandoffToSession(): void {
142
+ if (typeof window === 'undefined') return;
143
+ try {
144
+ const fn = window.__soundingsConstellationsGetHandoff;
145
+ if (typeof fn !== 'function') return;
146
+ const payload = fn();
147
+ if (!payload || typeof payload !== 'object') return;
148
+ const p = payload as { v?: number; graph?: { nodes?: unknown[] } };
149
+ if (p.v !== 1 || !p.graph?.nodes?.length) return;
150
+ try {
151
+ sessionStorage.setItem(SOUNDINGS_CONSTELLATIONS_HANDOFF_KEY, JSON.stringify(payload));
152
+ } catch (e) {
153
+ console.warn('[constellations] handoff too large for sessionStorage', e);
154
+ }
155
+ } catch (e) {
156
+ console.warn('[constellations] handoff persist', e);
157
+ }
158
+ }