diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index 499dcbf36..c69464d91 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -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', diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index 46a74fd61..4bd48d116 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -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 && Stopped · partial answer} {turn.status !== 'generating' && turn.answeredFrom !== 'graph' && candidate.id === turn.modelId)?.displayName ?? turn.modelId} historyOmitted={turn.historyOmitted} />} {turn.status === 'error' &&

{turn.error}

} - {index === turns.length - 1 && turn.status !== 'generating' && turn.answeredFrom !== 'graph' && } + {index === turns.length - 1 && turn.status !== 'generating' && } )} } diff --git a/graph-ui/src/browser-ai/relationship-answer.test.ts b/graph-ui/src/browser-ai/relationship-answer.test.ts index c5da4a689..e75b8ba9a 100644 --- a/graph-ui/src/browser-ai/relationship-answer.test.ts +++ b/graph-ui/src/browser-ai/relationship-answer.test.ts @@ -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(); diff --git a/graph-ui/src/browser-ai/relationship-answer.ts b/graph-ui/src/browser-ai/relationship-answer.ts index 581aea047..b45f9e1c1 100644 --- a/graph-ui/src/browser-ai/relationship-answer.ts +++ b/graph-ui/src/browser-ai/relationship-answer.ts @@ -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(); + 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; } diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index a1c707827..1005e1347 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -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.`,