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 <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-02 19:38:56 +02:00
1 parent fdb8142473
commit 62257fd418
4 files changed
+40 -6

No files matched your search

@@ -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}.`);
+12 -5
View File
@@ -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<typeof citedInterpretation>; mode?: 'interpretation'; shortened?: boolean };
type Explanation = { key: string; label: string; answer: string; status: string; error?: string; packet?: PreparedExplanationContext; citation?: ReturnType<typeof citedInterpretation>; 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 });
}
@@ -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');
@@ -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 } };
}