fix(ui): list callers only for the selected symbol and only on list questions

Without a detected subject every caller question counted as one about the
selection, and subjects were found only for camelCase names. "Who calls
get_queryset?" or "Wer ruft as_sql auf?" therefore listed the callers of
the selected JSONBAgg. The question is now read around its verb, so
snake_case and single-capital names are subjects too, and only a subject
that names the selection, one of its roots or "it"/"this" gets the graph
list.

The direction patterns also missed common phrasings and read "Welche
Funktion ruft X auf?" as a callee question. Singular "welche <Nomen> ruft
X auf", "rufen ... auf", "wo wird X aufgerufen", "which/what <noun> call
X" and "where is X called" now ask for callers; a plural noun with "ruft",
"was ruft" and "wird von X aufgerufen" ask for callees. Why, how, what-if
and explain questions go to the model even when they mention callers.

A listed answer now offers "Ask the model", which sends the same question
with the same evidence as a fresh request and replaces the list.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-02 19:37:27 +02:00
parent d53b008db8
commit ee5d4e8fdb
5 changed files with 153 additions and 44 deletions
@@ -903,6 +903,7 @@ describe('graph answers and answer limits', () => {
expect(answer).toContain('not generated by the model');
expect(container.querySelector('.cbm-chat-answer .cbm-chat-source-content')?.textContent).toContain('Incoming relationships: 23 from 12 symbols.');
expect([...container.querySelectorAll('button')].some(item => item.textContent === 'Retry')).toBe(false);
expect(button('Ask the model')).toBeDefined();
expect(container.querySelector('textarea')?.value).toBe('');
await type('Which of them are tests?'); await click('Send ↑');
const request = runtime.chat.mock.calls[0][0];
@@ -910,6 +911,22 @@ describe('graph answers and answer limits', () => {
expect(request[2].content).toContain('test_values_list');
});
it('hands a listed answer to the model on request, with its evidence and without the list as history', async () => {
const { props, runtime } = fixture();
await render({ ...props, proactiveSelection: jsonbAggEvidence() }); await click('Download & load');
await type('Who calls JSONBAgg?'); await click('Send ↑');
expect(runtime.chat).not.toHaveBeenCalled();
await click('Ask the model');
expect(runtime.chat).toHaveBeenCalledOnce();
const request = runtime.chat.mock.calls[0][0];
expect(request.map(message => message.role)).toEqual(['system', 'user']);
expect(request[1].content).toContain('Who calls JSONBAgg?');
expect(request[1].content).toContain('Incoming relationships: 23 from 12 symbols.');
expect(container.querySelectorAll('.cbm-chat-turn')).toHaveLength(1);
expect(container.querySelector('.cbm-chat-answer-text')?.textContent).toBe('Adds the two values.');
expect(button('Retry')).toBeDefined();
});
// 40 callers with long names in their own files: the snapshot names 24, a default prompt fewer.
const longCallers = () => {
const callers = Array.from({ length: 40 }, (_, index) => ({ id: 2000 + index, name: `caller_with_a_long_descriptive_name_number_${index}`, label: 'Function',
+10 -7
View File
@@ -361,7 +361,9 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
const nextContext = selectedGraph ? [...selectedContext.filter(item => item.id !== selectedGraph.id), selectedGraph] : selectedContext;
const extra = (retry ? retry.context ?? [] : nextContext).map(item => ({ ...item }));
let packet = retry?.evidence;
const currentGraph = !reader && proactiveSelection ? [{ ...proactiveSelection }] : [];
// A listed answer asked again goes to the model: the same question and evidence in a fresh request.
const ask = retry?.answeredFrom === 'graph';
const currentGraph = !retry && !reader && proactiveSelection ? [{ ...proactiveSelection }] : [];
const consume = (): void => {
setDraft(previous => previous === prompt ? '' : previous);
setSelectedContext(previous => previous.filter(item => !extra.some(sent => sent.id === item.id && sent.text === item.text)));
@@ -378,10 +380,11 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
consume();
return;
}
let history: ChatTurn[] = turns;
const earlier = ask ? turns.filter(item => item.id !== retry.id) : turns;
let history: ChatTurn[] = earlier;
const makeRequest = () => buildChatMessages(history, prompt, source, extra, reader, currentGraph, packet ? formatExplanationEvidence(packet) : undefined);
if (!retry && ((reader?.source?.text.length ?? 0) > 5000 || currentGraph.length)) packet = prepareExplanationContext(reader, currentGraph, 3200);
let request = retry ? retry.request.map(message => ({ ...message })) : makeRequest();
let request = retry && !ask ? retry.request.map(message => ({ ...message })) : makeRequest();
const queued = { cancelled: false }; manualRequest.current = queued;
const waitingEpoch = epoch.current;
if (automaticRun) {
@@ -402,7 +405,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
if (epoch.current !== ticket || stopRequested.current) return;
const limit = Math.min(limits.inputTokens, model.contextTokens - limits.outputTokens);
// Earlier turns give way first, oldest first; the question and its evidence stay.
while (!retry && count > limit && history.length) {
while ((!retry || ask) && count > limit && history.length) {
const characters = request.reduce((sum, message) => sum + message.content.length, 0);
history = trimChatHistory(history, Math.ceil((count - limit) * characters / Math.max(1, count)));
request = makeRequest(); count = await currentRuntime.countTokens(request);
@@ -418,8 +421,8 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
return;
}
const id = retry?.id ?? `local-turn-${crypto.randomUUID()}`;
const historyOmitted = retry ? retry.historyOmitted
: turns.filter(item => item.status !== 'error' && item.status !== 'generating' && !history.includes(item)).length;
const historyOmitted = retry && !ask ? retry.historyOmitted
: earlier.filter(item => item.status !== 'error' && item.status !== 'generating' && !history.includes(item)).length;
const turn: ChatTurn = { id, prompt, attachment: source, readerContext: reader, context: extra, evidence: packet, modelId: model.id, request, answer: '', status: 'generating',
...historyOmitted ? { historyOmitted } : {} };
activeTurn.current = id;
@@ -553,7 +556,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
{turn.status === 'stopped' && turn.answer && <small>Stopped · partial answer</small>}
{turn.status !== 'generating' && turn.answeredFrom !== 'graph' && <AnswerNotes shortened={turn.shortened} packet={turn.evidence} model={BROWSER_MODELS.find(candidate => candidate.id === turn.modelId)?.displayName ?? turn.modelId} historyOmitted={turn.historyOmitted} />}
{turn.status === 'error' && <p className="cbm-chat-turn-error" role="alert">{turn.error}</p>}
{index === turns.length - 1 && turn.status !== 'generating' && turn.answeredFrom !== 'graph' && <button type="button" className="cbm-chat-retry" disabled={phase !== 'ready'} onClick={() => { void send(turn); }}>Retry</button>}
{index === turns.length - 1 && turn.status !== 'generating' && <button type="button" className="cbm-chat-retry" disabled={phase !== 'ready'} onClick={() => { void send(turn); }}>{turn.answeredFrom === 'graph' ? browserChatText.askModel : 'Retry'}</button>}
</div>
</article>)}
</div>}
@@ -7,22 +7,41 @@ const line = (markdown: string, type: string) => markdown.split('\n').find(item
describe('caller and callee questions', () => {
it.each([
['Who calls JSONBAgg?', ['incoming'], 'en'],
['Who calls JSONBAgg? List every caller and the edge type.', ['incoming'], 'en'],
['List the callers of this class', ['incoming'], 'en'],
['Where is it called from? Show what calls it.', ['incoming'], 'en'],
['What is JSONBAgg called by?', ['incoming'], 'en'],
['What does JSONBAgg call?', ['outgoing'], 'en'],
['Show the callees', ['outgoing'], 'en'],
['Wer ruft JSONBAgg auf?', ['incoming'], 'de'],
['Welche Aufrufer hat die Klasse?', ['incoming'], 'de'],
['Was ruft JSONBAgg auf?', ['outgoing'], 'de'],
['Who calls it and what does it call?', ['incoming', 'outgoing'], 'en'],
] as const)('recognizes %s', (prompt, sides, language) => {
expect(relationshipQuestion(prompt)).toEqual({ sides, language });
['Who calls JSONBAgg?', ['incoming'], 'en', 'JSONBAgg'],
['Who calls JSONBAgg? List every caller and the edge type.', ['incoming'], 'en', 'JSONBAgg'],
['List the callers of this class', ['incoming'], 'en', undefined],
['Where is it called from? Show what calls it.', ['incoming'], 'en', undefined],
['What is JSONBAgg called by?', ['incoming'], 'en', 'JSONBAgg'],
['Which functions call JSONBAgg?', ['incoming'], 'en', 'JSONBAgg'],
['What functions call JSONBAgg?', ['incoming'], 'en', 'JSONBAgg'],
['Where is JSONBAgg called?', ['incoming'], 'en', 'JSONBAgg'],
['How many callers does JSONBAgg have?', ['incoming'], 'en', undefined],
['What does JSONBAgg call?', ['outgoing'], 'en', 'JSONBAgg'],
['Which functions does the JSONBAgg class call?', ['outgoing'], 'en', 'JSONBAgg'],
['Show the functions called by JSONBAgg', ['outgoing'], 'en', 'JSONBAgg'],
['Show the callees', ['outgoing'], 'en', undefined],
['Wer ruft JSONBAgg auf?', ['incoming'], 'de', 'JSONBAgg'],
['Wer ruft die Funktion auf?', ['incoming'], 'de', undefined],
['Welche Aufrufer hat die Klasse?', ['incoming'], 'de', undefined],
['Welche Funktion ruft JSONBAgg auf?', ['incoming'], 'de', 'JSONBAgg'],
['Welche Funktionen rufen JSONBAgg auf?', ['incoming'], 'de', 'JSONBAgg'],
['Welche Tests rufen JSONBAgg auf?', ['incoming'], 'de', 'JSONBAgg'],
['Wo wird JSONBAgg aufgerufen?', ['incoming'], 'de', 'JSONBAgg'],
['Von wem wird JSONBAgg aufgerufen?', ['incoming'], 'de', 'JSONBAgg'],
['Was ruft JSONBAgg auf?', ['outgoing'], 'de', 'JSONBAgg'],
['Welche Funktionen ruft JSONBAgg auf?', ['outgoing'], 'de', 'JSONBAgg'],
['Was wird von JSONBAgg aufgerufen?', ['outgoing'], 'de', 'JSONBAgg'],
['Who calls it and what does it call?', ['incoming', 'outgoing'], 'en', undefined],
['Who calls get_queryset?', ['incoming'], 'en', 'get_queryset'],
['Wer ruft as_sql auf?', ['incoming'], 'de', 'as_sql'],
['What does handle call?', ['outgoing'], 'en', 'handle'],
] as const)('recognizes %s', (prompt, sides, language, subject) => {
expect(relationshipQuestion(prompt)).toEqual({ sides, language, ...subject ? { subject } : {} });
});
it.each(['Explain JSONBAgg', 'What does this class do?', 'Was macht diese Klasse?', 'Write a test for JSONBAgg'])('leaves %s to the model', prompt => {
it.each(['Explain JSONBAgg', 'What does this class do?', 'Was macht diese Klasse?', 'Write a test for JSONBAgg',
'Explain how the callers use JSONBAgg', 'Why does the caller pass distinct=True?', 'What would break for callers if I rename JSONBAgg?',
'Who calls JSONBAgg and why?', 'Warum ruft der Test JSONBAgg auf?', 'Wie wird JSONBAgg von den Tests aufgerufen?'])('leaves %s to the model', prompt => {
expect(relationshipQuestion(prompt)).toBeUndefined();
});
});
@@ -92,7 +111,10 @@ describe('listed relationship answers', () => {
});
it('leaves questions about another symbol, attached non-Galaxy evidence and other questions to the model', () => {
expect(relationshipAnswer('Who calls BaseCommand?', [jsonbAggEvidence()])).toBeUndefined();
for (const prompt of ['Who calls BaseCommand?', 'Who calls get_queryset?', 'Wer ruft as_sql auf?', 'Who calls Aggregate?', 'What does handle call?',
'How many callers does get_queryset have?']) expect(relationshipAnswer(prompt, [jsonbAggEvidence()])).toBeUndefined();
expect(relationshipAnswer('Who calls the JSONBAgg class?', [jsonbAggEvidence()])).toBeDefined();
expect(relationshipAnswer('Wer ruft django.contrib.postgres.aggregates.general.JSONBAgg auf?', [jsonbAggEvidence()])).toBeDefined();
expect(relationshipAnswer('Who calls JSONBAgg?', [{ id: 'g', label: 'Callers', text: '{"kind":"graph-context-snapshot"}' }])).toBeUndefined();
expect(relationshipAnswer('Explain JSONBAgg', [jsonbAggEvidence()])).toBeUndefined();
expect(relationshipAnswer('Who calls it?', [jsonbAggEvidence()])).toBeDefined();
+87 -22
View File
@@ -1,29 +1,94 @@
import { classifyQuestion, identifierNamesIn, mentionsIn, quotedNamesIn } from '../compiler/question-classifier';
import { identifierNamesIn, mentionsIn, quotedNamesIn } from '../compiler/question-classifier';
import type { BrowserChatContext } from './chat-model';
import { readGalaxyEvidence, relationshipLine, scopeSentence, sideLoaded, type GalaxyEvidence } from './galaxy-evidence';
import { relationshipWords } from './strings';
export interface RelationshipQuestion { sides: ('incoming' | 'outgoing')[]; language: 'en' | 'de' }
/** "What does X call" asks for callees; the shared classifier does not have that class. */
const CALLEE_PATTERNS = [/\bwhat\s+(?:does|do|did|will)\s+.+?\s+call\b/i, /\bwhich\s+\w+\s+(?:does|do|did)\s+.+?\s+call\b/i,
/\bcallees?\b/i, /\bwas\s+ruft\b/i, /\bwelche\s+\w+\s+ruft\b/i, /\bwas\s+wird\s+von\b.+\baufgerufen\b/i];
const CALLER_PATTERNS = [/\bwhat\s+calls\b/i, /\bcallers?\b/i, /\bwer\s+ruft\b/i, /\bvon\s+wem\b/i];
const GERMAN = /\b(?:wer|welche\w*|ruft|rufen|aufrufer|aufgerufen|wem|wird)\b/i;
/** Caller and callee questions, in English or German. Anything else goes to the model. */
export function relationshipQuestion(prompt: string): RelationshipQuestion | undefined {
const callees = CALLEE_PATTERNS.some(pattern => pattern.test(prompt));
const callers = CALLER_PATTERNS.some(pattern => pattern.test(prompt)) || classifyQuestion(prompt).klass === 'who-calls';
if (!callers && !callees) return undefined;
return { sides: [...callers ? ['incoming' as const] : [], ...callees ? ['outgoing' as const] : []], language: GERMAN.test(prompt) ? 'de' : 'en' };
type Side = 'incoming' | 'outgoing';
export interface RelationshipQuestion {
sides: Side[];
language: 'en' | 'de';
/** The symbol named where the question puts its subject; undefined for "it", "this" or none. */
subject?: string;
}
/** A question that names another symbol is not about this selection. */
function aboutSelection(prompt: string, evidence: GalaxyEvidence): boolean {
const named = [...mentionsIn(prompt), ...quotedNamesIn(prompt), ...identifierNamesIn(prompt)];
const selected = new Set([evidence.label, ...evidence.roots.map(root => root.name)].map(name => name.toLowerCase()));
return !named.length || named.some(name => selected.has(name.toLowerCase()) || selected.has(name.split('.').pop()!.toLowerCase()));
const KIND = 'function|method|class|symbol|file|module|funktion|methode|klasse|datei|modul';
/** Where a pattern says {name}: an optional article and kind noun, the name as typed, an optional kind noun. */
const NAME = String.raw`(?:(?:the|a|an|der|die|das|den|dem|des)\s+)?(?:(?:${KIND})\s+)?[@\x60'"]?([A-Za-z_][\w.:/]*)[\x60'"]?(?:\s+(?:${KIND})\b)?`;
const pattern = (source: string) => new RegExp(source.replace('{name}', NAME), 'iu');
/** Phrasings that fix the direction and, where it is written, the subject. */
const PHRASINGS: { side: Side; pattern: RegExp }[] = [
...[
String.raw`\bwho\s+(?:else\s+)?(?:calls|invokes|uses)\s+{name}`,
String.raw`\bwhat\s+(?:calls|invokes)\s+{name}`,
String.raw`\b(?:which|what)\s+\w+\s+(?:calls?|invokes?)\s+{name}`,
String.raw`\bcall(?:ers?|\s+sites?)\s+(?:of|for|to)\s+{name}`,
String.raw`\bwhere\s+(?:is|are|does|do)\s+{name}\s+(?:\w+\s+)?(?:called|invoked|used)\b`,
String.raw`\b(?:is|are)\s+{name}\s+(?:called|invoked|used)\s+by\b`,
String.raw`\bwer\s+(?:ruft|benutzt|verwendet|nutzt)\s+{name}`,
String.raw`\bwelche\w*\s+[\p{L}\w]+\s+(?:rufen|benutzen|verwenden|nutzen)\s+{name}`,
String.raw`\b(?:wo|woher|von\s+wo)(?:\s+überall)?\s+wird\s+{name}\s+(?:\w+\s+)?(?:aufgerufen|benutzt|verwendet|genutzt)\b`,
String.raw`\bvon\s+wem\s+(?:wird|werden)\s+{name}`,
String.raw`\baufrufer\s+(?:von|für)\s+{name}`,
String.raw`\bwird\s+{name}\s+(?:\w+\s+)?aufgerufen\b`,
].map(source => ({ side: 'incoming' as const, pattern: pattern(source) })),
...[
String.raw`\bwhat\s+(?:does|do|did|will|can)\s+{name}\s+(?:call|invoke|use)\b`,
String.raw`\b(?:which|what)\s+\w+\s+(?:does|do|did|will|can)\s+{name}\s+(?:call|invoke|use)\b`,
String.raw`\bcallees?\s+(?:of|for)\s+{name}`,
String.raw`\bcalled\s+by\s+{name}`,
String.raw`\bwas\s+ruft\s+{name}`,
String.raw`\b(?:wird|werden)\s+von\s+{name}\s+(?:\w+\s+)?aufgerufen\b`,
].map(source => ({ side: 'outgoing' as const, pattern: pattern(source) })),
];
/** "Welche Funktion ruft X auf" asks for callers; with a plural noun, "ruft" has X as subject. */
const WHICH_CALLS = pattern(String.raw`\b(welche[rsnm]?)\s+([\p{L}\w]+)\s+ruft\s+{name}`);
/** Direction words without a written subject. */
const GENERAL: { side: Side; pattern: RegExp }[] = [
{ side: 'incoming', pattern: /\b(?:callers?|call\s+sites?|who\s+(?:calls|invokes|uses)|what\s+calls|wer\s+ruft|von\s+wem|aufrufer)\b/i },
{ side: 'outgoing', pattern: /\b(?:callees?|was\s+ruft)\b/i },
];
/** Why, how, what-if and explain questions want reasoning, not a list; "how many" still asks for one. */
const REASONING = /\b(?:why|how(?!\s+(?:many|often))|explain\w*|describe\w*|would|could|should|if|when|rename\w*|break\w*|impact\w*|warum|wieso|weshalb|wie(?!\s+(?:viele|oft))|erkl(?:ä|ae)r\w*|beschreib\w*|passiert|wäre|waere|würde|wuerde|könnte|koennte|sollte|wenn|falls|umbenenn\w*|auswirkung\w*)\b/iu;
const GERMAN = /\b(?:wer|welche\w*|ruft|rufen|aufrufer|aufgerufen|wem|wird|werden)\b/i;
/** Subjects that mean the current selection. */
const SELF = new Set(['it', 'this', 'that', 'these', 'those', 'here', 'es', 'dies', 'diese', 'dieser', 'dieses', 'diesen', 'diesem', 'sie', 'ihn',
'function', 'method', 'class', 'symbol', 'file', 'module', 'selection', 'funktion', 'methode', 'klasse', 'datei', 'modul', 'auswahl']);
/** Words a {name} can land on that are not a subject at all; the match is discarded. */
const NOT_A_NAME = new Set(['in', 'into', 'from', 'at', 'on', 'to', 'for', 'with', 'of', 'and', 'or', 'by', 'anything', 'anyone', 'something', 'someone',
'what', 'which', 'who', 'whom', 'all', 'every', 'auf', 'von', 'im', 'aus', 'und', 'oder', 'irgendwo', 'überhaupt', 'etwas', 'jemand',
'was', 'wer', 'wem', 'wen', 'alle', 'jede', 'jeder', 'jedes']);
/** Caller and callee questions that ask for a list, in English or German. Anything else goes to the model. */
export function relationshipQuestion(prompt: string): RelationshipQuestion | undefined {
if (REASONING.test(prompt)) return undefined;
const sides = new Set<Side>();
let subject: string | undefined, named = false;
const take = (side: Side, name: string | undefined): void => {
const word = name?.replace(/[.:/]+$/, '');
if (!word || NOT_A_NAME.has(word.toLowerCase())) return;
sides.add(side);
if (!SELF.has(word.toLowerCase()) && !subject) subject = word;
named = true;
};
for (const phrasing of PHRASINGS) take(phrasing.side, phrasing.pattern.exec(prompt)?.[1]);
const which = WHICH_CALLS.exec(prompt);
if (which) take(which[1].toLowerCase() === 'welche' && /(?:en|[^s]s)$/iu.test(which[2]) ? 'outgoing' : 'incoming', which[3]);
if (!named) for (const general of GENERAL) if (general.pattern.test(prompt)) sides.add(general.side);
if (!sides.size) return undefined;
return { sides: (['incoming', 'outgoing'] as const).filter(side => sides.has(side)), language: GERMAN.test(prompt) ? 'de' : 'en',
...subject ? { subject } : {} };
}
/** A question about another symbol is not about this selection. Without a written
* subject, any symbol-shaped word in the question still counts as naming one. */
function aboutSelection(prompt: string, question: RelationshipQuestion, evidence: GalaxyEvidence): boolean {
const selected = new Set([evidence.label, evidence.label.split('/').pop()!, ...evidence.roots.map(root => root.name)].map(name => name.toLowerCase()));
const matches = (name: string) => selected.has(name.toLowerCase()) || selected.has(name.split(/[.:]/).pop()!.toLowerCase());
if (question.subject) return matches(question.subject);
const snake = prompt.match(/\b[A-Za-z]\w*_\w+\b/g) ?? [];
const named = [...mentionsIn(prompt), ...quotedNamesIn(prompt), ...identifierNamesIn(prompt), ...snake];
return !named.length || named.some(matches);
}
const quote = (value: string) => `\`${value.replace(/`/g, "'")}\``;
@@ -54,13 +119,13 @@ function listed(question: RelationshipQuestion, evidence: GalaxyEvidence): strin
}
/** A complete, deterministic answer from the current Galaxy evidence, or undefined
* when the question is not about callers or callees of the selection. */
* when the question is not a list of callers or callees of the selection. */
export function relationshipAnswer(prompt: string, contexts: readonly BrowserChatContext[]): { markdown: string; context: BrowserChatContext } | undefined {
const question = relationshipQuestion(prompt);
if (!question) return undefined;
for (const context of contexts) {
const evidence = readGalaxyEvidence(context.text);
if (evidence && aboutSelection(prompt, evidence)) return { markdown: listed(question, evidence), context };
if (evidence && aboutSelection(prompt, question, evidence)) return { markdown: listed(question, evidence), context };
}
return undefined;
}
+2
View File
@@ -116,6 +116,8 @@ export const relationshipWords: { en: RelationshipWords; de: RelationshipWords }
/** Notes of the local chat dock about how an answer was produced or bounded. */
export const browserChatText = {
shortened: 'Shortened (token limit)',
/** Offered under a listed answer, which the model did not write. */
askModel: 'Ask the model',
capacity: (nodes: number, edges: number, model: string, shown: number) =>
`${nodes} nodes / ${edges} edges: too large for the local ${model} model; showing ${shown}`,
historyTrimmed: (count: number) => `${count} earlier ${count === 1 ? 'message was' : 'messages were'} left out to fit the input limit.`,