{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