fix(ui): say "Back to" and send a returned topic's own earlier turns

Coming back to a file or selection that already had turns showed "New
topic: <same file>. 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 <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-04 14:38:27 +02:00
1 parent 1b696eaada
commit c395bd57bb
10 files changed
+209 -51

No files matched your search

@@ -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 () => {
@@ -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?');
});
});
+16 -11
View File
@@ -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 <article className="cbm-chat-turn" key={turn.id}>
{turn.topic && index > 0 && turn.topic.key !== turns.slice(0, index).reverse().find(item => item.topic)?.topic?.key
&& <p className="cbm-chat-topic-break">{browserChatText.topicBreak(turn.topic.label)}</p>}
{turn.topic && divider && <p className="cbm-chat-topic-break">{divider === 'back' ? ownWords.backTo(turn.topic.label) : browserChatText.topicBreak(turn.topic.label)}</p>}
<div className="cbm-chat-question"><span className="cbm-chat-speaker">You</span><ChatMarkdown text={turn.prompt} />
{turn.askedModel && <small className="cbm-chat-asked-model">{ownWords.askedModel}</small>}</div>
<div className="cbm-chat-answer"><SourceDisclosure>
@@ -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> = {}): 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);
});
});
+4 -4
View File
@@ -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]);
});
+29 -15
View File
@@ -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<T extends BrowserChatTurn>(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';
}
+6 -1
View File
@@ -146,6 +146,11 @@ export function trimChatHistory<T extends BrowserChatTurn>(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 });
}
+19 -6
View File
@@ -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
+6
View File
@@ -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:`,
},
};
+8 -8
View File
@@ -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);
}