diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index a91444d92..24852c865 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -1049,6 +1049,22 @@ describe('per-selection explanation cache', () => { await settleSelection(); expect(runtime.chat).toHaveBeenCalledOnce(); }); + it('explains again when a returning selection now carries other evidence, as after a re-index', async () => { + vi.useFakeTimers(); const { props, runtime } = fixture(); + runtime.chat.mockResolvedValueOnce('Before the re-index.').mockResolvedValueOnce('Two layers.').mockResolvedValueOnce('After the re-index.'); + await render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence() }); await click('Download & load'); + await settleSelection(); expect(card()).toContain('Before the re-index.'); + await render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence({ depth: 2 }) }); await settleSelection(); + // The same selection, loading and then complete, after a re-index removed a caller. + await render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence({ state: 'loading-partial-preview' }) }); + expect(card()).toContain('Before the re-index.'); + await render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence({ edges: jsonbAggScope().edges.slice(2) }) }); + expect(card()).not.toContain('Before the re-index.'); + await settleSelection(); + expect(runtime.chat).toHaveBeenCalledTimes(3); + expect(card()).toContain('After the re-index.'); + }); + it('regenerates on request and keeps the cache bounded', async () => { vi.useFakeTimers(); const { props, runtime } = fixture(); let calls = 0; runtime.chat.mockImplementation(async () => `Explanation number ${++calls}.`); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index 50287daff..a94c7dc92 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -42,7 +42,7 @@ export interface BrowserChatDockProps { type Phase = 'off' | 'preparing' | 'ready' | 'counting' | 'generating' | 'removing'; type ChatTurn = BrowserChatTurn & { evidence?: PreparedExplanationContext }; -type Explanation = { key: string; label: string; answer: string; status: string; error?: string; packet?: PreparedExplanationContext; citation?: ReturnType; mode?: 'interpretation'; shortened?: boolean }; +type Explanation = { key: string; label: string; answer: string; status: string; error?: string; packet?: PreparedExplanationContext; citation?: ReturnType; mode?: 'interpretation'; shortened?: boolean; evidence?: string }; /** Finished explanations per selection, so returning to one does not run the model again. */ const EXPLANATION_CACHE_SIZE = 32; const initialModel = BROWSER_MODELS.find(model => model.availability === 'available')!; @@ -213,19 +213,25 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " } }, [selected?.key, automatic, proactive]); useEffect(() => { - // Returning to an explained selection shows what was already written. + // Returning to an explained selection shows what was already written, unless its + // complete scope now carries other evidence, as after a re-index. const cached = selected && explanations.current.get(selected.key); if (!cached) return; + if (selected.evidence && cached.evidence !== selected.evidence) { + explanations.current.delete(selected.key); lastAttempt.current = undefined; + setExplanation(previous => previous?.key === selected.key ? undefined : previous); + return; + } lastAttempt.current = cached.key; setExplanation(previous => previous?.key === cached.key ? previous : cached); - }, [selected?.key]); + }, [selected?.key, selected?.evidence]); useEffect(() => { if (!historyReady || !proactive || !automatic || !selected || selected.waiting || phase !== 'ready' || manualRequest.current || pending.current || lastAttempt.current === selected.key || explanations.current.has(selected.key)) return; const snapshot = selected; const timer = setTimeout(() => { void explain(snapshot); }, EXPLANATION_DELAY_MS); return () => clearTimeout(timer); - }, [selected?.key, selected?.waiting, phase, automatic, proactive, retryExplanation, historyReady]); + }, [selected?.key, selected?.waiting, selected?.evidence, phase, automatic, proactive, retryExplanation, historyReady]); const remember = (entry: Explanation): void => { const cache = explanations.current; cache.delete(entry.key); cache.set(entry.key, entry); @@ -269,7 +275,8 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " const result = parseExplanationResponse(answer, packet); if (!followExplanation.current) setNewExplanation(true); if (result.status === 'generated') { - const complete: Explanation = { key: snapshot.key, label: snapshot.label, answer: result.markdown, status: 'complete', mode: 'interpretation', packet, citation: result.citation, ...shortened ? { shortened } : {} }; + const complete: Explanation = { key: snapshot.key, label: snapshot.label, answer: result.markdown, status: 'complete', mode: 'interpretation', packet, citation: result.citation, + ...shortened ? { shortened } : {}, ...snapshot.evidence ? { evidence: snapshot.evidence } : {} }; remember(complete); setExplanation(complete); } else setExplanation({ key: snapshot.key, label: snapshot.label, answer: '', status: 'error', packet, error: result.reason }); } diff --git a/graph-ui/src/browser-ai/proactive-selection.test.ts b/graph-ui/src/browser-ai/proactive-selection.test.ts index 654b54b5d..0446a45b9 100644 --- a/graph-ui/src/browser-ai/proactive-selection.test.ts +++ b/graph-ui/src/browser-ai/proactive-selection.test.ts @@ -22,6 +22,15 @@ describe('selection identity for explanations', () => { expect(key('django-demo:galaxy', { id: 'o', label: 'OrderableAggMixin', text: JSON.stringify(other) })).not.toBe(base); }); + it('fingerprints the evidence of a complete scope only, so a re-index can retire a cached explanation', () => { + const complete = explanationInput('p:galaxy', undefined, jsonbAggEvidence())!; + expect(complete.evidence).toBe(jsonbAggEvidence().id); + expect(explanationInput('p:galaxy', undefined, jsonbAggEvidence({ state: 'loading-partial-preview' }))?.evidence).toBeUndefined(); + const reindexed = explanationInput('p:galaxy', undefined, jsonbAggEvidence({ edges: jsonbAggScope().edges.slice(2) }))!; + expect(reindexed.key).toBe(complete.key); + expect(reindexed.evidence).not.toBe(complete.evidence); + }); + it('marks incomplete Galaxy scopes as waiting and leaves other graph evidence unchanged', () => { expect(explanationInput('p:galaxy', undefined, jsonbAggEvidence())?.waiting).toBeUndefined(); expect(explanationInput('p:galaxy', undefined, jsonbAggEvidence({ state: 'loading-partial-preview' }))?.waiting).toBe('loading'); diff --git a/graph-ui/src/browser-ai/proactive-selection.ts b/graph-ui/src/browser-ai/proactive-selection.ts index 39fa82625..46733430b 100644 --- a/graph-ui/src/browser-ai/proactive-selection.ts +++ b/graph-ui/src/browser-ai/proactive-selection.ts @@ -11,6 +11,8 @@ export interface ExplanationInput { graph?: BrowserChatContext; /** A Galaxy scope that is not complete yet is shown, never explained. */ waiting?: 'loading' | 'partial'; + /** The complete scope's evidence: a cached explanation of other evidence (a re-index) is stale. */ + evidence?: string; } /** Snapshot exact evidence; generated UI event IDs are not selection identity. */ @@ -26,7 +28,7 @@ export function explanationInput(scope: string, reader?: BrowserChatReaderContex if (galaxy) { // The selection and how its scope is drawn; counts and loading state change while it loads. const key = JSON.stringify([scope, 'galaxy', galaxy.identity, galaxy.direction, galaxy.edgeTypes, galaxy.depth]); - return { key, label: graph.label, graph: { ...graph }, ...galaxy.state === 'complete' ? {} : { waiting: galaxy.state } }; + return { key, label: graph.label, graph: { ...graph }, ...galaxy.state === 'complete' ? { evidence: graph.id } : { waiting: galaxy.state } }; } return { key: JSON.stringify([scope, graph.label, graph.text]), label: graph.label, graph: { ...graph } }; }