diff --git a/graph-ui/src/browser-ai/BrowserChatDock.probes.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.probes.test.tsx new file mode 100644 index 000000000..1ee747d50 --- /dev/null +++ b/graph-ui/src/browser-ai/BrowserChatDock.probes.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { jsonbAggEvidence } from './galaxy-evidence.fixture'; +import { dockHarness, readerOf } from './chat-dock.fixture'; + +const dock = dockHarness(); +const TESTS = 'from django.test import TestCase\n\n\nclass AggregateTests(TestCase):\n def test_values_list(self):\n # test that the list is ok\n self.assertEqual(1, 1)\n'; + +/** A prompt that asks nothing gets example questions, also when the open file contains its word (B5). */ +describe('prompts without a question about an open file (B5)', () => { + it('answers "test" locally although the open file contains "test", and offers no model on that hint', async () => { + const { props, runtime } = dock.setup(); + await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(TESTS, 'tests/postgres_tests/test_aggregates.py') }); await dock.load(); + for (const prompt of ['test', 'hallo', 'ok']) { + await dock.ask(prompt); + expect(dock.answerOf(dock.last())).toMatch(/No question was recognized|wurde keine Frage erkannt/); + expect(dock.buttonsOf(dock.last())).toEqual([]); + } + expect(runtime.countTokens).not.toHaveBeenCalled(); + expect(runtime.chat).not.toHaveBeenCalled(); + // A real name of the file is a question. + await dock.ask('AggregateTests'); + expect(runtime.chat).toHaveBeenCalledOnce(); + }); + + it('offers no model under the hint for a Galaxy selection either', async () => { + const { props, runtime } = dock.setup(); + await dock.render({ ...props, proactiveSelection: jsonbAggEvidence() }); await dock.load(); + await dock.ask('test'); + expect(dock.answerOf(dock.last())).toContain('What does JSONBAgg do?'); + expect(dock.buttonsOf(dock.last())).toEqual([]); + expect(runtime.chat).not.toHaveBeenCalled(); + }); +}); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index a37dfd6ac..5d349e140 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -1589,15 +1589,13 @@ describe('general questions, prompts without a question and configuration files expect(runtime.chat).not.toHaveBeenCalled(); expect(answer()).toContain('No question was recognized in "test". You can ask, for example:'); for (const example of ['What does JSONBAgg do?', 'Who calls JSONBAgg?', 'What does JSONBAgg call?']) expect(answer()).toContain(example); - expect(buttons()).toEqual(['Ask the model']); + // The model only echoed such a prompt, so the hint offers no model (B5). + expect(buttons()).toEqual([]); await type('hallo'); await click('Send ↑'); - expect(answer()).toContain('In "hallo" wurde keine Frage erkannt. Frage zum Beispiel:'); + expect(answer()).toContain('In "hallo" wurde keine Frage erkannt.'); for (const example of ['Was macht JSONBAgg?', 'Wer ruft JSONBAgg auf?']) expect(answer()).toContain(example); - expect(buttons()).toEqual(['Modell fragen']); + expect(buttons()).toEqual([]); expect(runtime.chat).not.toHaveBeenCalled(); - await act(async () => button('Modell fragen').click()); - expect(runtime.chat).toHaveBeenCalledOnce(); - expect(runtime.chat.mock.calls[0][0].at(-1)!.content).toContain('User question:\nhallo'); // A follow-up does not carry the hint as an answer. await render({ ...props, readerContext: reader('repos:\n - repo: x\n', 'file', '.pre-commit-config.yaml') }); await type('?'); await click('Send ↑'); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index a38594c23..4510a07dc 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -66,8 +66,9 @@ const cachedInBrowser = (modelId: string) => isBrowserModelCached(getBrowserMode const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error); /** The names a Galaxy selection goes by: its label and its symbols with their qualified names. */ const galaxyNames = (galaxy: GalaxyEvidence): string[] => [galaxy.label, ...galaxy.roots.flatMap(root => [root.name, root.qualifiedName ?? ''])].filter(Boolean); -/** Answers the chat gives itself, which "Ask the model" sends to the model as the question they answer. */ -const ASKABLE = new Set(['graph', 'suggestion', 'grounded', 'file', 'hint']); +/** Answers the chat gives itself, which "Ask the model" sends to the model as the question they answer. + * Not the hint to a prompt without a question: the model only echoed "test" or "hallo" (B5). */ +const ASKABLE = new Set(['graph', 'suggestion', 'grounded', 'file']); const sizeLabel = (bytes: number): string => bytes >= 1_000_000_000 ? `${(bytes / 1_000_000_000).toFixed(2)} GB` : `${Math.ceil(bytes / 1_000_000)} MB`; /** Symbol sources read for explanations and questions, newest last. */ const SOURCE_CACHE_SIZE = 32; @@ -902,7 +903,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " {turn.status === 'error' &&

{turn.error}

} {index === turns.length - 1 && turn.answeredFrom === 'suggestion' && turn.suggestion && } - {index === turns.length - 1 && turn.status !== 'generating' && turn.answeredFrom !== 'local' && } + {index === turns.length - 1 && turn.status !== 'generating' && (!turn.answeredFrom || ASKABLE.has(turn.answeredFrom)) && } ; })} } diff --git a/graph-ui/src/browser-ai/question-intent.probes.test.ts b/graph-ui/src/browser-ai/question-intent.probes.test.ts new file mode 100644 index 000000000..283a621b0 --- /dev/null +++ b/graph-ui/src/browser-ai/question-intent.probes.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { knownNames, noQuestion } from './question-intent'; +import { jsonbAggEvidence } from './galaxy-evidence.fixture'; +import { readGalaxyEvidence } from './galaxy-evidence'; + +/** "test" went to the model whenever the open file contained the word, because every word of + * the file counted as a name the prompt could ask about (B5). */ +describe('greetings and probe words are never names of the open file (B5)', () => { + const file = 'import pytest\n\ndef test(): ...\n# hallo hi hello hey moin ok ja nein danke\nclass Aggregate: pass\n'; + + it('reads "test", "hallo" and the like as prompts without a question, even when the file contains them', () => { + const known = knownNames({ names: ['tests/test_aggregates.py'], texts: [file] }); + for (const prompt of ['test', 'hallo', 'hi', 'hello', 'hey', 'moin', 'ok', 'ja', 'nein', 'danke', '?', 'Test', 'hallo test']) expect(noQuestion(prompt, known), prompt).toBe(true); + // Real words of the file still ask about it. + expect(noQuestion('Aggregate', known)).toBe(false); + expect(noQuestion('pytest', known)).toBe(false); + }); + + it('counts a probe word when it is exactly the name of the selection', () => { + expect(noQuestion('test', knownNames({ names: ['test'], texts: [file] }))).toBe(false); + expect(noQuestion('test', knownNames({ names: ['django-demo.tests.test'], texts: [] }))).toBe(false); + // A longer name that only contains the word does not. + expect(noQuestion('test', knownNames({ names: ['test_jsonb_agg'], texts: [file] }))).toBe(true); + const galaxy = readGalaxyEvidence(jsonbAggEvidence().text)!; + expect(noQuestion('test', knownNames({ galaxy, names: ['JSONBAgg'], texts: [] }))).toBe(true); + }); +}); diff --git a/graph-ui/src/browser-ai/question-intent.ts b/graph-ui/src/browser-ai/question-intent.ts index f4a05d620..7c2b05593 100644 --- a/graph-ui/src/browser-ai/question-intent.ts +++ b/graph-ui/src/browser-ai/question-intent.ts @@ -107,14 +107,23 @@ export function noQuestionAnswer(typed: string, language: 'en' | 'de', subject: } /** The names a prompt may use for what is at hand: the selection, the symbols around it, the - * words of the open file or attached code. A prompt that names one of them is a question. */ + * words of the open file or attached code. A prompt that names one of them is a question. + * A greeting or probe ("test", "hallo", "ok") is none of them, even where the file has the + * word, unless it is exactly the selection's own name: "test" went to the model whenever the + * open file contained it (B5). */ export function knownNames(sources: { galaxy?: GalaxyEvidence; texts: readonly string[]; names: readonly string[] }): (word: string) => boolean { const words = new Set(); const add = (text: string) => { for (const word of text.match(/[\p{L}\p{N}_][\p{L}\p{N}_.-]*/gu) ?? []) words.add(word.toLowerCase()); }; sources.texts.forEach(add); - sources.names.forEach(name => { words.add(name.toLowerCase()); words.add((name.split(/[/.:]/).filter(Boolean).pop() ?? name).toLowerCase()); }); + const lastPart = (name: string) => (name.split(/[/.:]/).filter(Boolean).pop() ?? name).toLowerCase(); + sources.names.forEach(name => { words.add(name.toLowerCase()); words.add(lastPart(name)); }); const galaxy = sources.galaxy; if (galaxy) for (const group of [...galaxy.relationships.incoming, ...galaxy.relationships.outgoing]) for (const file of group.files) file.symbols.forEach(symbol => words.add(symbol.name.toLowerCase())); const own = sources.names.map(name => name.toLowerCase()).filter(name => name.length >= 4); - return word => words.has(word.toLowerCase()) || own.some(name => withinEdits(word.toLowerCase(), name, typoBudget(name))); + const ownNames = new Set(sources.names.flatMap(name => [name.toLowerCase(), lastPart(name)])); + return word => { + const lower = word.toLowerCase(); + if (GREETINGS.has(lower)) return ownNames.has(lower); + return words.has(lower) || own.some(name => withinEdits(lower, name, typoBudget(name))); + }; }