mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
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?');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:`,
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user