From 62257fd418ced47545e0cd10ce6b9fb235cc047e Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 19:38:56 +0200 Subject: [PATCH] fix(ui): retire a cached explanation when its scope's evidence changed The explanation cache is keyed by selection and scope shape only, so that a scope that is still loading finds its explanation. After a re-index of the same project in the same session, returning to a selection still showed the explanation of the old graph. Every cached explanation now keeps the id of the complete evidence it was written from. When the same selection completes with other evidence, the entry is dropped and the selection is explained again; while it loads, the cached text stays. Signed-off-by: Bernhard Jackiewicz --- .../src/browser-ai/BrowserChatDock.test.tsx | 16 ++++++++++++++++ graph-ui/src/browser-ai/BrowserChatDock.tsx | 17 ++++++++++++----- .../src/browser-ai/proactive-selection.test.ts | 9 +++++++++ graph-ui/src/browser-ai/proactive-selection.ts | 4 +++- 4 files changed, 40 insertions(+), 6 deletions(-) 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 } }; }