From c395bd57bb860e3c12fdd1ac7c7b4dde7042a618 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 14:38:27 +0200 Subject: [PATCH] fix(ui): say "Back to" and send a returned topic's own earlier turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coming back to a file or selection that already had turns showed "New topic: . Earlier messages are not sent with these questions." and sent none of them, so "Und was noch?" got "Ja, und noch." The divider now reads "Back to: X. Earlier messages about it are sent again." (German "Zurück zu: X. ..." for a German question), and the request carries the earlier turns of that topic again. Turns of other topics still stay out. A short follow-up right after a change of topic ("und", "mehr", "und was noch?", "and?", "more") has no turn of its topic to follow. It is now answered without the model: earlier messages were about another topic and are not sent, please ask the full question, with the example questions of a prompt that asks nothing. The chat check for K17 now expects the return to send the file's own earlier question and to say "Zurück zu". Signed-off-by: Bernhard Jackiewicz --- .../src/browser-ai/BrowserChatDock.test.tsx | 14 ++-- .../BrowserChatDock.topics.test.tsx | 75 +++++++++++++++++++ graph-ui/src/browser-ai/BrowserChatDock.tsx | 27 ++++--- .../browser-ai/chat-context.return.test.ts | 38 ++++++++++ graph-ui/src/browser-ai/chat-context.test.ts | 8 +- graph-ui/src/browser-ai/chat-context.ts | 44 +++++++---- graph-ui/src/browser-ai/chat-model.ts | 7 +- graph-ui/src/browser-ai/question-intent.ts | 25 +++++-- graph-ui/src/browser-ai/strings.ts | 6 ++ graph-ui/tools/handtest-fixes-chat.mjs | 16 ++-- 10 files changed, 209 insertions(+), 51 deletions(-) create mode 100644 graph-ui/src/browser-ai/BrowserChatDock.topics.test.tsx create mode 100644 graph-ui/src/browser-ai/chat-context.return.test.ts diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index 5d349e140..506686218 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -1008,7 +1008,7 @@ describe('graph answers and answer limits', () => { expect(runtime.chat.mock.calls[2][0].map(message => message.content).join('\n')).not.toContain('flake8'); }); - it('starts a file or selection fresh when the reader comes back to it, as the divider says (K17)', async () => { + it('carries the earlier turns of a file or selection the reader comes back to, as the divider says (K17, B4)', async () => { const { props, runtime } = fixture(); runtime.chat.mockResolvedValueOnce('This runs the flake8 linter on Python files.'); const workflow = reader('name: New contributor message', 'file', '.github/workflows/new_contributor_pr.yml'); @@ -1019,15 +1019,17 @@ describe('graph answers and answer limits', () => { await render({ ...props, selectionScope: 'django-demo:explore', readerContext: workflow }); await type('Und was noch?'); await click('Send ↑'); const request = runtime.chat.mock.calls[2][0]; - expect(request.filter(message => message.role === 'assistant')).toHaveLength(0); - expect(request.map(message => message.content).join('\n')).not.toContain('flake8'); - expect(request.filter(message => message.role === 'user').map(message => message.content.split('\n').at(-1))).toEqual(['Und was noch?']); + // Its own earlier turn comes along; nothing of JSONBAgg does. + expect(request.filter(message => message.role === 'assistant').map(message => message.content)).toEqual(['This runs the flake8 linter on Python files.']); + expect(request.map(message => message.content).join('\n')).not.toContain('JSONBAgg'); + expect(request.filter(message => message.role === 'user').map(message => message.content.split('\n').at(-1))).toEqual(['welche Aktion nutzt dieses File?', 'Und was noch?']); expect([...container.querySelectorAll('.cbm-chat-topic-break')].map(item => item.textContent)).toEqual([ 'New topic: JSONBAgg. Earlier messages are not sent with these questions.', - 'New topic: .github/workflows/new_contributor_pr.yml. Earlier messages are not sent with these questions.']); + 'Zurück zu: .github/workflows/new_contributor_pr.yml. Die früheren Nachrichten dazu werden wieder mitgeschickt.']); // Within the returned topic the conversation goes on. await type('Welche Jobs?'); await click('Send ↑'); - expect(runtime.chat.mock.calls[3][0].filter(message => message.role === 'user').map(message => message.content.split('\n').at(-1))).toEqual(['Und was noch?', 'Welche Jobs?']); + expect(runtime.chat.mock.calls[3][0].filter(message => message.role === 'user').map(message => message.content.split('\n').at(-1))) + .toEqual(['welche Aktion nutzt dieses File?', 'Und was noch?', 'Welche Jobs?']); }); it('marks names in an answer that are not in the file or the graph facts (K12)', async () => { diff --git a/graph-ui/src/browser-ai/BrowserChatDock.topics.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.topics.test.tsx new file mode 100644 index 000000000..894b26595 --- /dev/null +++ b/graph-ui/src/browser-ai/BrowserChatDock.topics.test.tsx @@ -0,0 +1,75 @@ +// @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 WORKFLOW = 'name: New contributor message\n\non:\n pull_request_target:\n types: [opened]\n'; +const PATH = '.github/workflows/new_contributor_pr.yml'; + +/** Back on the same file the divider said "New topic", its earlier turns were not sent, and + * "Und was noch?" got "Ja, und noch." (B4). */ +describe('coming back to a topic and follow-ups without context (B4)', () => { + it('says "Back to" and sends the earlier turns of that topic again, never those of another one', async () => { + const { props, runtime } = dock.setup(); + runtime.chat.mockResolvedValueOnce('It uses actions/first-interaction@v1.').mockResolvedValueOnce('JSONBAgg is an aggregate.'); + await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(WORKFLOW, PATH) }); await dock.load(); + await dock.ask('welche Aktion nutzt dieses File?'); + await dock.render({ ...props, selectionScope: 'django-demo:galaxy', proactiveSelection: jsonbAggEvidence() }); + await dock.ask('Explain JSONBAgg line by line.'); + await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(WORKFLOW, PATH) }); + await dock.ask('Und was noch?'); + const request = runtime.chat.mock.calls.at(-1)![0]; + expect(dock.questionsIn(request)).toEqual(['welche Aktion nutzt dieses File?', 'Und was noch?']); + expect(request.filter(message => message.role === 'assistant').map(message => message.content)).toEqual(['It uses actions/first-interaction@v1.']); + expect(request.map(message => message.content).join('\n')).not.toContain('JSONBAgg'); + expect(dock.dividers()).toEqual(['New topic: JSONBAgg. Earlier messages are not sent with these questions.', + `Zurück zu: ${PATH}. Die früheren Nachrichten dazu werden wieder mitgeschickt.`]); + }); + + it('says "Back to" in English for an English question', async () => { + const { props } = dock.setup(); + await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(WORKFLOW, PATH) }); await dock.load(); + await dock.ask('Which action does it use?'); + await dock.render({ ...props, selectionScope: 'django-demo:galaxy', proactiveSelection: jsonbAggEvidence() }); + await dock.ask('Who calls JSONBAgg?'); + await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(WORKFLOW, PATH) }); + await dock.ask('Which trigger starts it?'); + expect(dock.dividers().at(-1)).toBe(`Back to: ${PATH}. Earlier messages about it are sent again.`); + }); + + it('answers a follow-up right after a topic change locally and asks for the full question', async () => { + const { props, runtime } = dock.setup(); + await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(WORKFLOW, PATH) }); await dock.load(); + await dock.ask('welche Aktion nutzt dieses File?'); + await dock.render({ ...props, selectionScope: 'django-demo:galaxy', proactiveSelection: jsonbAggEvidence() }); + const calls = runtime.chat.mock.calls.length; + await dock.ask('und was noch?'); + expect(runtime.chat.mock.calls.length).toBe(calls); + const answer = dock.answerOf(dock.last()); + expect(answer).toContain('"und was noch?" bezieht sich auf frühere Nachrichten. Die betrafen ein anderes Thema und werden mit Fragen zu JSONBAgg nicht mitgeschickt.'); + expect(answer).toContain('Stell die Frage bitte vollständig, zum Beispiel:'); + for (const example of ['Was macht JSONBAgg?', 'Wer ruft JSONBAgg auf?']) expect(answer).toContain(example); + expect(answer).toContain('Ohne das Modell beantwortet.'); + expect(dock.buttonsOf(dock.last())).toEqual([]); + await dock.ask('more'); + expect(dock.answerOf(dock.last())).toContain('"more" refers to earlier messages. Those were about another topic and are not sent with questions about JSONBAgg.'); + expect(runtime.chat.mock.calls.length).toBe(calls); + // The hints are not earlier turns: a real question afterwards goes to the model without them. + await dock.ask('Explain JSONBAgg line by line.'); + expect(dock.questionsIn(runtime.chat.mock.calls.at(-1)![0])).toEqual(['Explain JSONBAgg line by line.']); + }); + + // Without another topic before it, a follow-up may refer to the explanation card and goes to + // the model as before ("uses original source for follow-ups" in BrowserChatDock.test.tsx). + it('reads "mehr" as German', async () => { + const { props, runtime } = dock.setup(); + await dock.render({ ...props, selectionScope: 'django-demo:galaxy', proactiveSelection: jsonbAggEvidence() }); await dock.load(); + await dock.ask('Who calls JSONBAgg?'); + await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(WORKFLOW, PATH) }); + await dock.ask('mehr'); + expect(runtime.chat).not.toHaveBeenCalled(); + expect(dock.answerOf(dock.last())).toContain(`"mehr" bezieht sich auf frühere Nachrichten. Die betrafen ein anderes Thema und werden mit Fragen zu ${PATH} nicht mitgeschickt.`); + expect(dock.answerOf(dock.last())).toContain('Was macht new_contributor_pr.yml?'); + }); +}); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index 4510a07dc..4df71d053 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -3,7 +3,7 @@ import type { BrowserAiProgress } from './browser-ai-controller'; import { createBrowserChatRuntime, type BrowserChatRuntime } from './browser-ai-runtime'; import { BROWSER_MODELS, getBrowserModel, isBrowserModelCached, removeBrowserModelCache, type BrowserModel } from './model-policy'; import { keepAgent, loadProgress, offerAgent, peekAgent, takeAgent, type AgentHandover, type LoadProgress } from './agent-handover'; -import { buildChatMessages, selectionLocation, snapshotAttachment, snapshotReaderContext, trimChatHistory, type BrowserChatAttachment, type BrowserChatContext, type BrowserChatReaderContext, type BrowserChatSource, type BrowserChatTurn } from './chat-model'; +import { buildChatMessages, selectionLocation, sentInHistory, snapshotAttachment, snapshotReaderContext, trimChatHistory, type BrowserChatAttachment, type BrowserChatContext, type BrowserChatReaderContext, type BrowserChatSource, type BrowserChatTurn } from './chat-model'; import { explanationInput, EXPLANATION_DELAY_MS, type ExplanationInput } from './proactive-selection'; import { prepareExplanationContext, selectionSummary, type PreparedExplanationContext } from './explanation-context'; import { AUTO_INPUT_TOKENS, AUTO_OUTPUT_TOKENS, citedInterpretation, explanationMode, explanationSentence, namesNotIn, parseExplanationResponse, explanationMessages, formatExplanationEvidence } from './explanation-response'; @@ -15,11 +15,11 @@ import { isGpuRuntimeFailure, BrowserRuntimeFatalError } from './runtime-fault'; import ChatMarkdown from './ChatMarkdown'; import AgentSettingsDialog from './AgentSettingsDialog'; import { useChatHistory } from './use-chat-history'; -import { chatTopic, followedTopic, missingContextAnswer, questionLanguage, topicHistory } from './chat-context'; +import { chatTopic, contextFreeFollowUp, followedTopic, missingContextAnswer, questionLanguage, topicDivider, topicHistory } from './chat-context'; import { isDataFile, readerFacts } from './file-facts'; import { fileOutline } from './file-outline'; import { readGalaxyEvidence, type GalaxyEvidence } from './galaxy-evidence'; -import { generalQuestion, knownNames, noQuestion, noQuestionAnswer } from './question-intent'; +import { followUpAnswer, generalQuestion, knownNames, noQuestion, noQuestionAnswer, type ExampleSubject } from './question-intent'; import './browser-chat.css'; export type { BrowserChatAttachment, BrowserChatContext, BrowserChatReaderContext, BrowserChatSource } from './chat-model'; @@ -646,11 +646,16 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " // A prompt that asks nothing ("test", "hallo") gets questions it could ask, not an echo of the model (C6). const known = knownNames({ galaxy, names: galaxy ? galaxyNames(galaxy) : fileSource ? [fileSource.path] : [], texts: [fileSource?.text ?? '', source?.text ?? '', ...currentGraph.filter(() => !galaxy).map(item => item.label), ...extra.map(item => item.label)] }); - if (!retry && noQuestion(prompt, known)) { - const subject = galaxy ? { kind: 'galaxy' as const, name: galaxy.label } : fileSource?.kind === 'selection' || source ? { kind: 'marked' as const } - : { kind: 'other' as const, name: fileSource ? fileSource.path.split('/').pop()! : topic?.label ?? '' }; + const subject: ExampleSubject = galaxy ? { kind: 'galaxy', name: galaxy.label } : fileSource?.kind === 'selection' || source ? { kind: 'marked' } + : { kind: 'other', name: fileSource ? fileSource.path.split('/').pop()! : topic?.label ?? '' }; + // "und was noch?" right after a change of topic would reach the model without context: + // this topic has no earlier turn, and those of other topics are not sent (B4). + const hint = !retry && noQuestion(prompt, known) ? noQuestionAnswer(prompt, language, subject) + : !retry && topic && contextFreeFollowUp(prompt) && !topicHistory(turns, topic).some(sentInHistory) && turns.some(item => item.topic && item.topic.key !== topic.key) + ? followUpAnswer(prompt, language, subject, topic.label) : undefined; + if (hint) { setTurns(previous => [...previous, { id: `local-turn-${crypto.randomUUID()}`, prompt, attachment: source, readerContext: reader, context: extra, topic, replyLanguage: language, - ...currentGraph[0] ? { listedFrom: currentGraph[0] } : {}, modelId: model.id, request: [], answer: noQuestionAnswer(prompt, language, subject), status: 'complete', answeredFrom: 'hint' }]); + ...currentGraph[0] ? { listedFrom: currentGraph[0] } : {}, modelId: model.id, request: [], answer: hint, status: 'complete', answeredFrom: 'hint' }]); consume(); return; } @@ -667,8 +672,8 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " // listed facts and one checked model sentence, in the language of the question (C5). const grounded = !retry && !source && !extra.length && galaxy && generalQuestion(prompt, galaxyNames(galaxy)) === 'general' ? { graph: currentGraph[0], summary: selectionSummary(currentGraph[0], language), subject: selectionSubject(currentGraph[0]) } : undefined; - // Answers about another file or selection stay out, and so do those from before the - // last change of topic: an earlier wrong answer must not become evidence for this one (K17). + // Answers about another file or selection stay out (K17); those of this topic come along, + // also from before the reader went elsewhere and came back (B4). const earlier = topicHistory(ask ? turns.filter(item => item.id !== retry.id) : turns, topic); let history: ChatTurn[] = earlier; const makeRequest = () => buildChatMessages(history, prompt, source, extra, reader, currentGraph, packet ? formatExplanationEvidence(packet) : undefined, questionLanguage(prompt)); @@ -882,9 +887,9 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " {turns.map((turn, index) => { const replyWords = relationshipWords[turn.replyLanguage === 'de' ? 'de' : 'en']; // The model's own answers carry their note in the language of the question (B1). const ownWords = chatRound3Text[turn.replyLanguage ?? questionLanguage(turn.prompt)]; + const divider = topicDivider(turns, index); return
- {turn.topic && index > 0 && turn.topic.key !== turns.slice(0, index).reverse().find(item => item.topic)?.topic?.key - &&

{browserChatText.topicBreak(turn.topic.label)}

} + {turn.topic && divider &&

{divider === 'back' ? ownWords.backTo(turn.topic.label) : browserChatText.topicBreak(turn.topic.label)}

}
You {turn.askedModel && {ownWords.askedModel}}
diff --git a/graph-ui/src/browser-ai/chat-context.return.test.ts b/graph-ui/src/browser-ai/chat-context.return.test.ts new file mode 100644 index 000000000..efc72a807 --- /dev/null +++ b/graph-ui/src/browser-ai/chat-context.return.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { chatTopic, contextFreeFollowUp, topicDivider, topicHistory } from './chat-context'; +import type { BrowserChatTurn } from './chat-model'; +import { jsonbAggEvidence } from './galaxy-evidence.fixture'; + +const source = { id: 'a', text: 'x', path: 'a.yml', project: 'p', startLine: 1, startColumn: 1, endLine: 1, endColumn: 2, sourceVersion: 'v', kind: 'file' as const }; +const file = chatTopic('p:explore', { reader: { project: 'p', path: 'a.yml', status: 'ready', source } })!; +const selected = chatTopic('p:galaxy', { graph: jsonbAggEvidence() })!; +const turn = (topic: BrowserChatTurn['topic'], extra: Partial = {}): BrowserChatTurn => + ({ id: String(Math.random()), prompt: 'q', modelId: 'm', request: [], answer: 'a', status: 'complete', topic, ...extra }); + +/** Coming back to a file was called "New topic", its earlier turns were dropped, and "Und was + * noch?" got "Ja, und noch." (B4). */ +describe('a topic the reader comes back to (B4)', () => { + it('carries the earlier turns of the same topic again, never those of another one', () => { + const [first, second] = [turn(file), turn(file)]; + const other = turn(selected); + expect(topicHistory([first, other, second], file)).toEqual([first, second]); + expect(topicHistory([first, other], file)).toEqual([first]); + expect(topicHistory([first, other, second], selected)).toEqual([other]); + expect(topicHistory([first, turn(undefined, { answeredFrom: 'local' }), second], file)).toEqual([first, second]); + }); + + it('marks a new topic and a return to an earlier one, and nothing within a topic', () => { + const turns = [turn(file), turn(file), turn(selected), turn(selected), turn(file), turn(undefined, { answeredFrom: 'local' }), turn(file)]; + expect(turns.map((_, index) => topicDivider(turns, index))).toEqual([undefined, undefined, 'new', undefined, 'back', undefined, undefined]); + }); + + it.each(['und', 'Und?', 'mehr', 'und was noch?', 'Und was noch?', 'was noch', 'noch mehr', 'und sonst?', 'weiter', 'warum?', + 'and?', 'more', 'what else?', 'anything else', 'and then?', 'go on', 'continue', 'why?'])('reads "%s" as a follow-up without context of its own', prompt => { + expect(contextFreeFollowUp(prompt)).toBe(true); + }); + + it.each(['Und was macht JSONBAgg?', 'mehr über die Jobs', 'more about the hooks', 'what else does it call?', 'Welche Jobs?', 'test', 'warum ruft es super auf?'])( + 'reads "%s" as a question of its own', prompt => { + expect(contextFreeFollowUp(prompt)).toBe(false); + }); +}); diff --git a/graph-ui/src/browser-ai/chat-context.test.ts b/graph-ui/src/browser-ai/chat-context.test.ts index 9dde9c501..095077539 100644 --- a/graph-ui/src/browser-ai/chat-context.test.ts +++ b/graph-ui/src/browser-ai/chat-context.test.ts @@ -61,12 +61,12 @@ describe('what a question is about', () => { describe('which earlier turns a question carries (K17)', () => { const file = chatTopic('p:explore', { reader: { project: 'p', path: 'a.yml', status: 'ready', source: { ...attachment, path: 'a.yml', kind: 'file' } } })!; const selected = chatTopic('p:galaxy', { graph: jsonbAggEvidence() })!; - it('carries only the turns since the topic last changed, as the divider says', () => { + it('carries the turns of its own topic, never those of another one', () => { const [first, second] = [turn(file), turn(file)]; expect(topicHistory([first, second], file)).toEqual([first, second]); - expect(topicHistory([first, turn(selected)], file)).toEqual([]); - // Coming back to a file after another topic starts it fresh: the old wrong answer stays out. - expect(topicHistory([first, turn(selected), second], file)).toEqual([second]); + // Coming back to a file after another topic carries its earlier turns again (B4). + expect(topicHistory([first, turn(selected)], file)).toEqual([first]); + expect(topicHistory([first, turn(selected), second], file)).toEqual([first, second]); expect(topicHistory([turn(selected), first, second], file)).toEqual([first, second]); }); diff --git a/graph-ui/src/browser-ai/chat-context.ts b/graph-ui/src/browser-ai/chat-context.ts index 2e799649a..332a54ef7 100644 --- a/graph-ui/src/browser-ai/chat-context.ts +++ b/graph-ui/src/browser-ai/chat-context.ts @@ -21,20 +21,33 @@ export function chatTopic(scope: string, sources: { reader?: BrowserChatReaderCo return undefined; } -/** The earlier turns a question about `topic` carries: those since the topic last changed. - * A file or selection the reader comes back to starts fresh, as the divider above it says, - * so an earlier wrong answer about it is not sent again (K17). Replies without a topic - * (no context) neither belong to nor end a topic. */ +/** The earlier turns a question about `topic` carries: its own, also from before the reader + * went to another file or selection and came back, and never those of another topic (K17). + * Coming back started fresh, so "Und was noch?" got "Ja, und noch." (B4). Replies without a + * topic (no context) belong to none. */ export function topicHistory(turns: readonly T[], topic: ChatTopic | undefined): T[] { - if (!topic) return []; - const run: T[] = []; - for (let index = turns.length - 1; index >= 0; index--) { - const turn = turns[index]; - if (!turn.topic) continue; - if (turn.topic.key !== topic.key) break; - run.unshift(turn); - } - return run; + return topic ? turns.filter(turn => turn.topic?.key === topic.key) : []; +} + +/** The divider above turn `index`: "new" for the first turn about another topic, "back" for + * a topic that has earlier turns, nothing within a topic (B4). */ +export function topicDivider(turns: readonly BrowserChatTurn[], index: number): 'new' | 'back' | undefined { + const topic = turns[index]?.topic; + if (!topic || index === 0) return undefined; + const earlier = turns.slice(0, index); + if ([...earlier].reverse().find(item => item.topic)?.topic?.key === topic.key) return undefined; + return earlier.some(item => item.topic?.key === topic.key) ? 'back' : 'new'; +} + +/** Follow-ups that ask nothing of their own: they continue the answer before (B4). */ +const FOLLOW_UPS = new Set(['und', 'and', 'mehr', 'more', 'noch mehr', 'und mehr', 'and more', 'mehr davon', 'more of that', 'mehr details', 'more details', 'details', + 'was noch', 'und was noch', 'was sonst', 'und sonst', 'sonst', 'sonst noch was', 'sonst noch etwas', 'und sonst noch was', 'noch was', 'noch etwas', 'und noch', + 'weiter', 'und weiter', 'und dann', 'erzähl mehr', 'erzähl mir mehr', 'warum', 'wieso', 'weshalb', 'und warum', + 'what else', 'and what else', 'anything else', 'else', 'and then', 'then', 'continue', 'go on', 'tell me more', 'why', 'and why', 'how so']); + +/** A short follow-up that needs the turns before it: "und", "mehr", "und was noch?", "and?", "more". */ +export function contextFreeFollowUp(prompt: string): boolean { + return FOLLOW_UPS.has(prompt.toLowerCase().replace(/[?!.,;:…]+/g, ' ').replace(/\s+/g, ' ').trim()); } /** A question without its own context follows up on explicitly attached code or context @@ -53,8 +66,9 @@ export function followedTopic(turns: readonly BrowserChatTurn[], scope: string): /** German when the question reads German; the chat's own replies follow the question. * "was" and "die" are English words too and decide nothing on their own. Short questions - * with typos ("was mcht die klasse") and greetings ("hallo") read German too (C4). */ -const GERMAN = /[äöüß]|\b(?:ich|du|der|das|und|ist|nicht|wie|wer|wo|warum|wieso|weshalb|kannst|kann|mir|mich|diese[rsnm]?|dise[rsnm]?|datei|sagen|erkl\w*|zeig\w*|welche\w*|gibt|wird|macht|mach|mcht|tut|klasse|klase|funktion|methode|modul|hallo|moin|servus|danke|bitte|kurz\w*|antwort\w*|sehr|ein|eine[rsnm]?|sind|noch|auch|hier|zeile\w*|genau|beschreib\w*|detailliert\w*|ausf\w*)\b/i; + * with typos ("was mcht die klasse") and greetings ("hallo") read German too (C4), and so do + * follow-ups such as "mehr" (B4). */ +const GERMAN = /[äöüß]|\b(?:ich|du|der|das|und|ist|nicht|wie|wer|wo|warum|wieso|weshalb|kannst|kann|mir|mich|diese[rsnm]?|dise[rsnm]?|datei|sagen|erkl\w*|zeig\w*|welche\w*|gibt|wird|macht|mach|mcht|tut|klasse|klase|funktion|methode|modul|hallo|moin|servus|danke|bitte|kurz\w*|antwort\w*|sehr|ein|eine[rsnm]?|sind|noch|auch|hier|zeile\w*|genau|beschreib\w*|detailliert\w*|ausf\w*|mehr|weiter|sonst|dann)\b/i; export function questionLanguage(prompt: string): 'en' | 'de' { return GERMAN.test(prompt) ? 'de' : 'en'; } diff --git a/graph-ui/src/browser-ai/chat-model.ts b/graph-ui/src/browser-ai/chat-model.ts index ed5592e24..c999c969c 100644 --- a/graph-ui/src/browser-ai/chat-model.ts +++ b/graph-ui/src/browser-ai/chat-model.ts @@ -146,6 +146,11 @@ export function trimChatHistory(turns: readonly T[], * "was macht diese klasse?" (C4). */ export const ANSWER_LANGUAGE = { en: 'Answer in English.', de: 'Answer in German.' } as const; +/** Whether a turn goes into the history of a later request: an answer, not a suggestion, + * the chat's own hint or its reply that a question has no context. */ +export const sentInHistory = (turn: BrowserChatTurn): boolean => turn.status !== 'error' && turn.status !== 'generating' + && turn.answeredFrom !== 'suggestion' && turn.answeredFrom !== 'local' && turn.answeredFrom !== 'hint'; + export function buildChatMessages(turns: readonly BrowserChatTurn[], prompt: string, attachment?: BrowserChatAttachment, context: readonly BrowserChatContext[] = [], readerContext?: BrowserChatReaderContext, currentContext: readonly BrowserChatContext[] = [], currentEvidence?: string, language?: keyof typeof ANSWER_LANGUAGE): BrowserChatMessage[] { const reader = snapshotReaderContext(readerContext); @@ -156,7 +161,7 @@ export function buildChatMessages(turns: readonly BrowserChatTurn[], prompt: str + JSON.stringify(currentContext.map(({ label, text }) => ({ label, text }))) + '\n--- END CURRENT GRAPH DATA ---' : '') }]; for (const turn of turns) { // A suggestion is a question back to the reader, not an answer the model should build on. - if (turn.status === 'error' || turn.status === 'generating' || turn.answeredFrom === 'suggestion' || turn.answeredFrom === 'local' || turn.answeredFrom === 'hint') continue; + if (!sentInHistory(turn)) continue; messages.push({ role: 'user', content: userMessage(turn.prompt, reader || turn.readerContext ? undefined : turn.attachment, turn.context) }); if (turn.answer) messages.push({ role: 'assistant', content: turn.answer }); } diff --git a/graph-ui/src/browser-ai/question-intent.ts b/graph-ui/src/browser-ai/question-intent.ts index 7c2b05593..8ce098f3f 100644 --- a/graph-ui/src/browser-ai/question-intent.ts +++ b/graph-ui/src/browser-ai/question-intent.ts @@ -1,6 +1,6 @@ import type { GalaxyEvidence } from './galaxy-evidence'; import { typoBudget, withinEdits } from './relationship-answer'; -import { noQuestionText } from './strings'; +import { chatRound3Text, noQuestionText } from './strings'; /** What a prompt asks of the current selection or open file, before any model sees it. * @@ -96,14 +96,27 @@ export function noQuestion(prompt: string, known: (word: string) => boolean): bo /** What the examples of a prompt without a question are about. */ export type ExampleSubject = { kind: 'galaxy' | 'other'; name: string } | { kind: 'marked' }; +/** Two or three questions that work for the selection, as a Markdown list. */ +function examples(language: 'en' | 'de', subject: ExampleSubject): string { + const text = noQuestionText[language]; + const items = subject.kind === 'marked' ? [text.markedDoes, text.markedInDetail] + : subject.kind === 'galaxy' ? [text.whatDoes(subject.name), text.whoCalls(subject.name), text.whatCalls(subject.name)] + : [text.whatDoes(subject.name), text.inDetail(subject.name)]; + return items.map(example => `- ${example}`).join('\n'); +} +const shownPrompt = (typed: string) => typed.replace(/\s+/g, ' ').replace(/["`]/g, "'").trim().slice(0, 40); + /** "No question was recognized in "test"." and two or three questions that work for the selection (C6). */ export function noQuestionAnswer(typed: string, language: 'en' | 'de', subject: ExampleSubject): string { const text = noQuestionText[language]; - const examples = subject.kind === 'marked' ? [text.markedDoes, text.markedInDetail] - : subject.kind === 'galaxy' ? [text.whatDoes(subject.name), text.whoCalls(subject.name), text.whatCalls(subject.name)] - : [text.whatDoes(subject.name), text.inDetail(subject.name)]; - const shown = typed.replace(/\s+/g, ' ').replace(/["`]/g, "'").trim().slice(0, 40); - return `${text.heading(shown)}\n\n${examples.map(example => `- ${example}`).join('\n')}\n\n_${text.note}_`; + return `${text.heading(shownPrompt(typed))}\n\n${examples(language, subject)}\n\n_${text.note}_`; +} + +/** A follow-up ("und was noch?") right after a change of topic: the earlier turns are about + * another topic and are not sent, so the model would answer it without context ("Ja, und + * noch."). It asks for the full question with the same examples (B4). */ +export function followUpAnswer(typed: string, language: 'en' | 'de', subject: ExampleSubject, topic: string): string { + return `${chatRound3Text[language].followUp(shownPrompt(typed), topic)}\n\n${examples(language, subject)}\n\n_${noQuestionText[language].note}_`; } /** The names a prompt may use for what is at hand: the selection, the symbols around it, the diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index b389df598..5d5a0dfe9 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -391,6 +391,10 @@ const englishRound3Text = { /** Under every answer the model wrote freely (B1). */ modelNote: 'Generated by the local model; it can be wrong.', askAgain: 'Ask again', + /** Above the first question about a file or selection that has earlier turns (B4). */ + backTo: (label: string) => `Back to: ${label}. Earlier messages about it are sent again.`, + /** A follow-up ("und was noch?") right after a change of topic (B4). */ + followUp: (typed: string, label: string) => `"${typed}" refers to earlier messages. Those were about another topic and are not sent with questions about ${label}. Please ask the full question, for example:`, }; export const chatRound3Text: { en: typeof englishRound3Text; de: typeof englishRound3Text } = { en: englishRound3Text, @@ -398,5 +402,7 @@ export const chatRound3Text: { en: typeof englishRound3Text; de: typeof englishR askedModel: 'An das lokale Modell gestellt', modelNote: 'Vom lokalen Modell erzeugt; kann falsch sein.', askAgain: 'Erneut fragen', + backTo: (label: string) => `Zurück zu: ${label}. Die früheren Nachrichten dazu werden wieder mitgeschickt.`, + followUp: (typed: string, label: string) => `"${typed}" bezieht sich auf frühere Nachrichten. Die betrafen ein anderes Thema und werden mit Fragen zu ${label} nicht mitgeschickt. Stell die Frage bitte vollständig, zum Beispiel:`, }, }; diff --git a/graph-ui/tools/handtest-fixes-chat.mjs b/graph-ui/tools/handtest-fixes-chat.mjs index 5a9cb46ac..28a4a6b20 100644 --- a/graph-ui/tools/handtest-fixes-chat.mjs +++ b/graph-ui/tools/handtest-fixes-chat.mjs @@ -384,7 +384,7 @@ async function k17(page) { const header1 = await page.locator('.cbm-chat-header .cbm-chat-new').boundingBox(); if (header1) await shot(page, 'K17', 'header-crop', 'Crop of the chat header with the New conversation button.', { clip: { x: header1.x - 260, y: header1.y - 14, width: header1.width + 320, height: header1.height + 28 } }); const assistantTurns = (request?.messages ?? []).filter((message) => message.role === 'assistant').length; - // Back to the same workflow file: its earlier answer is not sent again, as the divider says (review finding). + // Back to the same workflow file: "Zurück zu" and its own earlier turn is sent again, nothing of JSONBAgg (B4). await tab(page, 'explore'); await page.waitForFunction(() => /New contributor message/.test(document.body.innerText), null, { timeout: 30000 }).catch(() => {}); await explanationDone(page); @@ -393,17 +393,17 @@ async function k17(page) { const [back] = await sentSince(page, mark); const backText = promptText(back); const divider = await page.locator('.cbm-chat-topic-break').allInnerTexts(); - await shot(page, 'K17', 'explore-return', 'Back in Explore on the same file: a divider "New topic: .github/workflows/new_contributor_pr.yml"; the earlier answer is not in the prompt.'); + await shot(page, 'K17', 'explore-return', 'Back in Explore on the same file: a divider "Zurück zu: .github/workflows/new_contributor_pr.yml"; the earlier question about the file is in the prompt, nothing of JSONBAgg.'); const dividerBox = await page.locator('.cbm-chat-topic-break').last().boundingBox(); if (dividerBox) await shot(page, 'K17', 'explore-return-crop', 'Crop of the divider above the returned question.', { clip: { x: dividerBox.x - 8, y: Math.max(0, dividerBox.y - 8), width: dividerBox.width + 16, height: 220 } }); - const backAssistant = (back?.messages ?? []).filter((message) => message.role === 'assistant').length; + const backAssistant = (back?.messages ?? []).filter((message) => message.role === 'assistant'); const backUsers = (back?.messages ?? []).filter((message) => message.role === 'user').map((message) => message.content.split('\n').at(-1)); - check('K17', 'Earlier answers about another file are not resent, also not on the way back; topic breaks and New conversation visible', Boolean(request) && !/Welche Aktion nutzt dieses aktuelle File/.test(text) - && !text.includes(exploreAnswer.slice(-60).trim()) && assistantTurns === 0 && /New conversation/.test(header) && divider.some((item) => /New topic: JSONBAgg/.test(item)) - && Boolean(back) && backAssistant === 0 && !/Welche Aktion nutzt dieses aktuelle File/.test(backText) && !backText.includes(exploreAnswer.slice(-60).trim()) - && JSON.stringify(backUsers) === JSON.stringify(['Und was noch?']) && divider.some((item) => /New topic: \.github\/workflows\/new_contributor_pr\.yml\. Earlier messages are not sent/.test(item)), + check('K17', 'Earlier answers about another topic are not sent; coming back to a file says "Zurück zu" and sends that file\'s own earlier turn; topic breaks and New conversation visible', Boolean(request) && !/Welche Aktion nutzt dieses aktuelle File/.test(text) + && assistantTurns === 0 && /New conversation/.test(header) && divider.some((item) => /New topic: JSONBAgg/.test(item)) + && Boolean(back) && backAssistant.length === 1 && !/JSONBAgg/.test(backText) + && JSON.stringify(backUsers) === JSON.stringify(['Welche Aktion nutzt dieses aktuelle File?', 'Und was noch?']) && divider.some((item) => /Zurück zu: \.github\/workflows\/new_contributor_pr\.yml\. Die früheren Nachrichten dazu werden wieder mitgeschickt/.test(item)), { assistantMessagesInPrompt: assistantTurns, flake8InPrompt: /flake8/i.test(text), exploreQuestionInPrompt: /aktuelle File/.test(text), header, divider, exploreAnswer: exploreAnswer.slice(0, 200), galaxyAnswer: galaxyAnswer.slice(0, 200), - returnPrompt: { assistantMessages: backAssistant, userMessages: backUsers, earlierExploreAnswerInPrompt: backText.includes(exploreAnswer.slice(-60).trim()) }, returnAnswer: returnAnswer.slice(0, 200) }); + returnPrompt: { assistantMessages: backAssistant.map((message) => message.content.slice(0, 160)), userMessages: backUsers, jsonbAggInPrompt: /JSONBAgg/.test(backText) }, returnAnswer: returnAnswer.slice(0, 200) }); await save('K17', 'prompt-galaxy.txt', text); await save('K17', 'prompt-explore-return.txt', backText); }