Merge third round: chat keeps listed answers, code facts in grounded answers, code file questions, topic return, probe words, INI outline and file purpose

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-04 15:54:51 +02:00
commit b5a927a14a
28 files changed
+1365 -145

No files matched your search

@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { JSONB_AGG_CALLERS, jsonbAggEvidence } from './galaxy-evidence.fixture';
import { dockHarness, readerOf } from './chat-dock.fixture';
const dock = dockHarness();
const PRE_COMMIT = 'repos:\n - repo: https://github.com/PyCQA/isort\n rev: 5.13.2\n hooks:\n - id: isort\n';
/** "Ask the model" under an answer the chat gave itself put the model's text in its place: the
* 11 listed callers became one sentence naming one of them, and nothing said who wrote it (B1). */
describe('the model answer to a listed, grounded or file answer is a turn of its own (B1)', () => {
it('keeps the listed callers and adds the model answer below them, marked as generated', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('The call to JSONBAgg is made by the test_jsonb_agg method.');
await dock.render({ ...props, proactiveSelection: jsonbAggEvidence() }); await dock.load();
await dock.ask('Who calls JSONBAgg?');
await dock.click('Ask the model', dock.last());
expect(runtime.chat).toHaveBeenCalledOnce();
const [listed, asked] = dock.turns();
expect(dock.turns()).toHaveLength(2);
for (const name of JSONB_AGG_CALLERS) expect(dock.answerOf(listed)).toContain(name);
expect(dock.answerOf(listed)).toContain('not generated by the model');
expect(asked.querySelector('.cbm-chat-question')?.textContent).toContain('Who calls JSONBAgg?');
expect(asked.querySelector('.cbm-chat-asked-model')?.textContent).toBe('Asked the local model');
expect(dock.answerOf(asked)).toBe('The call to JSONBAgg is made by the test_jsonb_agg method.');
expect(dock.notesOf(asked)).toContain('Generated by the local model; it can be wrong.');
// Only the last turn offers a button: asking again repeats the model's answer in place.
expect(dock.buttonsOf(listed)).toEqual([]);
expect(dock.buttonsOf(asked)).toEqual(['Ask again']);
await dock.click('Ask again', asked);
expect(runtime.chat).toHaveBeenCalledTimes(2);
expect(runtime.chat.mock.calls[1][0]).toEqual(runtime.chat.mock.calls[0][0]);
expect(dock.turns()).toHaveLength(2);
});
it('keeps German facts and adds the model answer with a German note', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('`JSONBAgg` setzt `function` auf "JSONB_AGG".').mockResolvedValueOnce('Diese Klasse ist eine Aggregation.');
await dock.render({ ...props, proactiveSelection: jsonbAggEvidence() }); await dock.load();
await dock.ask('was macht diese klasse? sehr kurze antwort');
const facts = dock.answerOf(dock.last());
expect(facts).toContain('Ausgewählt: JSONBAgg (Class)');
await dock.click('Modell fragen', dock.last());
const [grounded, asked] = dock.turns();
expect(dock.turns()).toHaveLength(2);
expect(dock.answerOf(grounded)).toBe(facts);
expect(asked.querySelector('.cbm-chat-asked-model')?.textContent).toBe('An das lokale Modell gestellt');
expect(dock.answerOf(asked)).toBe('Diese Klasse ist eine Aggregation.');
expect(dock.notesOf(asked)).toContain('Vom lokalen Modell erzeugt; kann falsch sein.');
expect(dock.buttonsOf(asked)).toEqual(['Erneut fragen']);
});
it('keeps a file outline and adds the model answer below it', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('This file configures pre-commit hooks.');
await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(PRE_COMMIT, '.pre-commit-config.yaml') }); await dock.load();
await dock.ask('What does this file do?');
const outline = dock.answerOf(dock.last());
expect(outline).toContain('repos (list of 1):');
await dock.click('Ask the model', dock.last());
expect(dock.turns()).toHaveLength(2);
expect(dock.answerOf(dock.turns()[0])).toBe(outline);
expect(dock.answerOf(dock.last())).toBe('This file configures pre-commit hooks.');
// The model is asked about the file, and the outline is not handed to it as an earlier answer.
const request = runtime.chat.mock.calls[0][0];
expect(request[0].content).toContain('--- BEGIN EXACT SOURCE TEXT ---\nrepos:');
expect(request.filter(message => message.role === 'assistant')).toHaveLength(0);
// A follow-up carries both: the outline and the model's answer.
await dock.ask('Which hook runs isort?');
expect(dock.questionsIn(runtime.chat.mock.calls[1][0])).toEqual(['What does this file do?', 'What does this file do?', 'Which hook runs isort?']);
});
it('marks every free model answer as generated, in the language of the question', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('It adds two values.').mockResolvedValueOnce('Es addiert zwei Werte.');
await dock.render({ ...props, readerContext: readerOf('export const sum = (a: number, b: number) => a + b;', 'src/sum.ts') }); await dock.load();
await dock.ask('Which values does it add, and where is it used?');
expect(dock.notesOf(dock.last())).toContain('Generated by the local model; it can be wrong.');
expect(dock.buttonsOf(dock.last())).toEqual(['Ask again']);
expect(dock.last().querySelector('.cbm-chat-asked-model')).toBeNull();
await dock.ask('Welche Werte addiert es, und wo wird es benutzt?');
expect(dock.notesOf(dock.last())).toContain('Vom lokalen Modell erzeugt; kann falsch sein.');
expect(dock.buttonsOf(dock.last())).toEqual(['Erneut fragen']);
});
});
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { jsonbAggEvidence } from './galaxy-evidence.fixture';
import { dockHarness } from './chat-dock.fixture';
const dock = dockHarness();
/** "was macht diese klasse? sehr kurze antwort" on JSONBAgg got only counts: the model's sentence
* claimed "Arrays" and was dropped, and nothing said what the class declares (B2). */
describe('a general question about a selected symbol says what its code declares (B2)', () => {
afterEach(() => vi.useRealTimers());
it('lists the class line and its assignments under "Im Quelltext:" even when the sentence is dropped', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('JSONBAgg ist eine Aggregation, die Werte in einen JSON-Array umwandelt.');
await dock.render({ ...props, proactiveSelection: jsonbAggEvidence() }); await dock.load();
await dock.ask('was macht diese klasse? sehr kurze antwort');
expect(runtime.chat).toHaveBeenCalledOnce();
const answer = dock.answerOf(dock.last());
expect(answer).toContain('Ausgewählt: JSONBAgg (Class)');
expect(answer).toContain('Im Quelltext:');
for (const line of ['class JSONBAgg(OrderableAggMixin, Aggregate):', 'function = "JSONB_AGG"', 'template = "%(function)s(%(distinct)s%(expressions)s %(order_by)s)"',
'allow_distinct = True', 'output_field = JSONField()']) expect(dock.last().querySelector('.cbm-chat-answer-text pre')?.textContent).toContain(line);
expect(answer).not.toContain('JSON-Array');
expect(answer).toContain('wurde weggelassen');
// The facts come first, then the code, then who wrote what.
expect(answer.indexOf('Ausgewählt:')).toBeLessThan(answer.indexOf('Im Quelltext:'));
expect(answer.indexOf('Im Quelltext:')).toBeLessThan(answer.indexOf('wurde weggelassen'));
});
it('says it in English for an English question, with the checked sentence after the code', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('`JSONBAgg` sets `function` to "JSONB_AGG" and allows distinct values.');
await dock.render({ ...props, proactiveSelection: jsonbAggEvidence() }); await dock.load();
await dock.ask('What does this do?');
const answer = dock.answerOf(dock.last());
expect(answer).toContain('In the source:');
expect(answer).toContain('class JSONBAgg(OrderableAggMixin, Aggregate):');
expect(answer.indexOf('In the source:')).toBeLessThan(answer.indexOf('JSONBAgg sets function to "JSONB_AGG"'));
});
it('shows no code without source: the listed facts stay the answer', async () => {
const { props, runtime } = dock.setup();
await dock.render({ ...props, proactiveSelection: jsonbAggEvidence(), readSource: vi.fn(async () => { throw new Error('offline'); }) }); await dock.load();
await dock.ask('What does this do?');
expect(runtime.chat).not.toHaveBeenCalled();
expect(dock.answerOf(dock.last())).not.toContain('In the source:');
});
it('adds the same lines to the automatic explanation card', async () => {
vi.useFakeTimers(); const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('JSONBAgg turns rows into arrays.');
await dock.render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence() }); await dock.load();
await act(async () => { await vi.advanceTimersByTimeAsync(650); });
const card = dock.card()!.textContent ?? '';
expect(card).toContain('Incoming relationships: 23 from 12 symbols');
expect(card).toContain('In the source:');
expect(card).toContain('output_field = JSONField()');
expect(card).not.toContain('arrays');
});
});
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { dockHarness, readerOf } from './chat-dock.fixture';
const dock = dockHarness();
const GENERAL = '"""Aggregate functions for PostgreSQL."""\nfrom django.db.models import Aggregate\n\n\n'
+ 'class ArrayAgg(OrderableAggMixin, Aggregate):\n function = "ARRAY_AGG"\n allow_distinct = True\n\n\n'
+ 'class JSONBAgg(OrderableAggMixin, Aggregate):\n function = "JSONB_AGG"\n allow_distinct = True\n output_field = JSONField()\n';
const PATH = 'django/contrib/postgres/aggregates/general.py';
const JSONB_AGG = 'class JSONBAgg(OrderableAggMixin, Aggregate):\n function = "JSONB_AGG"\n allow_distinct = True\n output_field = JSONField()';
/** Short general questions about an open code file or marked code got the free model answer
* the reader had called garbled, while a Galaxy selection got listed facts and one checked
* sentence (B3). */
describe('short general questions about an open code file or marked code (B3)', () => {
it('answers "was macht diese datei?" with facts read from the file and one checked model text, in German', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('Die Datei definiert die Aggregate `ArrayAgg` und `JSONBAgg`.');
await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(GENERAL, PATH) }); await dock.load();
await dock.ask('was macht diese datei?');
expect(runtime.chat).toHaveBeenCalledOnce();
const [request, , options] = runtime.chat.mock.calls[0];
expect(options).toMatchObject({ generationProfile: 'automatic-explanation' });
expect(request.at(-1)!.content).toContain('Answer in German.');
expect(request.at(-1)!.content).toContain('class JSONBAgg(OrderableAggMixin, Aggregate):');
const answer = dock.answerOf(dock.last());
expect(answer).toContain('general.py: Python-Quelltext, 13 Zeilen.');
expect(answer).toContain('Docstring des Moduls: "Aggregate functions for PostgreSQL."');
expect(answer).toContain('Definitionen auf oberster Ebene (2): class ArrayAgg, class JSONBAgg.');
expect(answer).toContain('Die Datei definiert die Aggregate ArrayAgg und JSONBAgg.');
expect(answer).toContain('Fakten aus der Datei gelesen; der Text danach ist vom Modell erzeugt.');
expect(dock.buttonsOf(dock.last())).toEqual(['Modell fragen']);
});
it('answers a question about marked code that is one class with its lines and the code facts, in English', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('It declares the JSONB_AGG aggregate with `json_helper`.');
await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(JSONB_AGG, PATH, 'selection', { start: 11, end: 14 }) }); await dock.load();
await dock.ask('explain this');
expect(runtime.chat).toHaveBeenCalledOnce();
const answer = dock.answerOf(dock.last());
expect(answer).toContain('Marked lines 11-14 of general.py (Python source file).');
expect(answer).toContain('In the source:');
expect(dock.last().querySelector('.cbm-chat-answer-text pre')?.textContent).toContain('output_field = JSONField()');
// The model named something the code does not have: its text is left out.
expect(answer).not.toContain('json_helper');
expect(answer).toContain("Read from the file. The model's text named something the file does not show and was left out.");
});
it('still sends questions that are not general to the model as before', async () => {
const { props, runtime } = dock.setup();
await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(GENERAL, PATH) }); await dock.load();
await dock.ask('Which class sets allow_distinct and why?');
expect(runtime.chat).toHaveBeenCalledOnce();
expect(runtime.chat.mock.calls[0][2]?.generationProfile).toBeUndefined();
expect(dock.notesOf(dock.last())).toContain('Generated by the local model; it can be wrong.');
await dock.ask('Erklär diese Datei Zeile für Zeile');
expect(runtime.chat.mock.calls[1][2]?.generationProfile).toBeUndefined();
});
});
@@ -0,0 +1,23 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { dockHarness, readerOf } from './chat-dock.fixture';
const dock = dockHarness();
const TOX = '[tox]\nminversion = 4.0\nenvlist =\n py3\n flake8\n\n[testenv]\nchangedir = tests\ncommands =\n {envpython} runtests.py {posargs}\n';
/** "was macht diese datei?" on tox.ini went to the model, which wrote "Testverwendungen" (B6, B7). */
describe('what an INI file does, answered from the file (B6, B7)', () => {
it('answers from tox.ini with its purpose and sections, without the model', async () => {
const { props, runtime } = dock.setup();
await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(TOX, 'tox.ini') }); await dock.load();
await dock.ask('was macht diese datei?');
expect(runtime.chat).not.toHaveBeenCalled();
const answer = dock.answerOf(dock.last());
expect(answer).toContain('tox.ini: INI-Konfiguration, 10 Zeilen.');
expect(answer).toContain('tox-Konfiguration: die Testumgebungen, die tox anlegt, und die Befehle, die es in jeder ausführt.');
expect(answer).toContain('[tox]: minversion 4.0; envlist: py3, flake8');
expect(answer).toContain('[testenv]: changedir tests; commands {envpython} runtests.py {posargs}');
expect(answer).toContain('Aus der Datei gelesen, nicht vom Modell erzeugt.');
expect(dock.buttonsOf(dock.last())).toEqual(['Modell fragen']);
});
});
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { jsonbAggEvidence } from './galaxy-evidence.fixture';
import { dockHarness, readerOf } from './chat-dock.fixture';
const dock = dockHarness();
const TESTS = 'from django.test import TestCase\n\n\nclass AggregateTests(TestCase):\n def test_values_list(self):\n # test that the list is ok\n self.assertEqual(1, 1)\n';
/** A prompt that asks nothing gets example questions, also when the open file contains its word (B5). */
describe('prompts without a question about an open file (B5)', () => {
it('answers "test" locally although the open file contains "test", and offers no model on that hint', async () => {
const { props, runtime } = dock.setup();
await dock.render({ ...props, selectionScope: 'django-demo:explore', readerContext: readerOf(TESTS, 'tests/postgres_tests/test_aggregates.py') }); await dock.load();
for (const prompt of ['test', 'hallo', 'ok']) {
await dock.ask(prompt);
expect(dock.answerOf(dock.last())).toMatch(/No question was recognized|wurde keine Frage erkannt/);
expect(dock.buttonsOf(dock.last())).toEqual([]);
}
expect(runtime.countTokens).not.toHaveBeenCalled();
expect(runtime.chat).not.toHaveBeenCalled();
// A real name of the file is a question.
await dock.ask('AggregateTests');
expect(runtime.chat).toHaveBeenCalledOnce();
});
it('offers no model under the hint for a Galaxy selection either', async () => {
const { props, runtime } = dock.setup();
await dock.render({ ...props, proactiveSelection: jsonbAggEvidence() }); await dock.load();
await dock.ask('test');
expect(dock.answerOf(dock.last())).toContain('What does JSONBAgg do?');
expect(dock.buttonsOf(dock.last())).toEqual([]);
expect(runtime.chat).not.toHaveBeenCalled();
});
});
@@ -139,7 +139,7 @@ describe('persistent local browser chat', () => {
it('sends exact selection text once and keeps its immutable snapshot for the sent turn', async () => {
const { props, runtime } = fixture();
const original = { ...selection };
await render({ ...props, attachment: original }); await click('Download & load'); await type('Explain this part'); await click('Send ↑');
await render({ ...props, attachment: original }); await click('Download & load'); await type('How is this part computed?'); await click('Send ↑');
const sent = runtime.chat.mock.calls[0][0].at(-1)!;
expect(attachmentData(sent)).toMatchObject({ path: selection.path, sourceVersion: selection.sourceVersion });
expect(sent.content.includes(selection.text)).toBe(true);
@@ -172,7 +172,7 @@ describe('persistent local browser chat', () => {
it('retries the same request and attachment without consuming a new selection', async () => {
const { props, runtime } = fixture(); runtime.chat.mockRejectedValueOnce(new Error('GPU interrupted'));
await render({ ...props, attachment: selection }); await click('Download & load'); await type('Explain'); await click('Send ↑');
await render({ ...props, attachment: selection }); await click('Download & load'); await type('How is it computed?'); await click('Send ↑');
const request = runtime.chat.mock.calls[0][0];
expect(container.textContent).toContain('GPU interrupted');
await render({ ...props, attachment: { ...selection, id: 'new-selection', text: 'new source' } });
@@ -186,7 +186,7 @@ describe('persistent local browser chat', () => {
const { props, runtime } = fixture();
const answer = deferred<string>(); let stream!: (chunk: string) => void;
runtime.chat.mockImplementationOnce((_messages, onToken) => { stream = onToken; return answer.promise; });
await render({ ...props, attachment: selection }); await click('Download & load'); await type('Explain'); await click('Send ↑');
await render({ ...props, attachment: selection }); await click('Download & load'); await type('How is it computed?'); await click('Send ↑');
await act(async () => stream('First part.')); await click('Stop');
expect(runtime.stop).toHaveBeenCalledOnce(); expect(button('Stopping…').disabled).toBe(true);
await type('Next question');
@@ -238,7 +238,7 @@ describe('persistent local browser chat', () => {
it('does not move scroll position while the reader is inspecting earlier output', async () => {
const { props, runtime } = fixture(); const answer = deferred<string>(); let stream!: (chunk: string) => void;
runtime.chat.mockImplementationOnce((_messages, onToken) => { stream = onToken; return answer.promise; });
await render({ ...props, attachment: selection }); await click('Download & load'); await type('Explain'); await click('Send ↑');
await render({ ...props, attachment: selection }); await click('Download & load'); await type('How is it computed?'); await click('Send ↑');
const log = container.querySelector('.cbm-chat-transcript') as HTMLDivElement;
Object.defineProperties(log, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { configurable: true, value: 200 } });
log.scrollTop = 100;
@@ -259,7 +259,7 @@ describe('persistent local browser chat', () => {
it('unloads an active worker and ignores stale output while retaining its sent question', async () => {
const { props, runtime } = fixture(); const answer = deferred<string>(); let stream!: (chunk: string) => void;
runtime.chat.mockImplementationOnce((_messages, onToken) => { stream = onToken; return answer.promise; });
await render({ ...props, attachment: selection }); await click('Download & load'); await type('Explain'); await click('Send ↑'); await models();
await render({ ...props, attachment: selection }); await click('Download & load'); await type('How is it computed?'); await click('Send ↑'); await models();
await act(async () => stream('Partial answer')); await click('Unload model');
await act(async () => { stream('Ignored stale chunk'); answer.resolve('Ignored stale final'); });
expect(runtime.dispose).toHaveBeenCalledOnce(); expect(container.textContent).toContain('Partial answer');
@@ -281,7 +281,7 @@ describe('persistent local browser chat', () => {
expect(runtime.chat.mock.calls[0][0].at(-1)?.content).not.toContain('changed graph');
expect(container.querySelector('.cbm-chat-pending')).toBeNull();
expect(container.querySelector('.cbm-chat-turn:last-child .cbm-chat-source-content pre')?.textContent).toBe('entry → sum');
await click('Retry'); expect(runtime.chat.mock.calls[1][0]).toEqual(runtime.chat.mock.calls[0][0]);
await click('Ask again'); expect(runtime.chat.mock.calls[1][0]).toEqual(runtime.chat.mock.calls[0][0]);
});
it('sends controlled graph selection once with literal code and retains it for retry', async () => {
@@ -291,7 +291,7 @@ describe('persistent local browser chat', () => {
const first = runtime.chat.mock.calls[0][0].at(-1)!.content;
expect(first).toContain(selection.text); expect(first).toContain(pendingContext.text); expect(callbacks.onContextConsumed).toHaveBeenCalledExactlyOnceWith('galaxy-1');
expect(container.querySelector('[aria-label="Remove graph selection"]')).toBeNull();
await click('Retry'); expect(runtime.chat.mock.calls[1][0]).toEqual(runtime.chat.mock.calls[0][0]);
await click('Ask again'); expect(runtime.chat.mock.calls[1][0]).toEqual(runtime.chat.mock.calls[0][0]);
await render({ ...props, ...callbacks, pendingContext }); await type('Follow up'); await click('Send ↑');
expect(runtime.chat.mock.calls[2][0].at(-1)!.content).toBe('Answer in English.\n\nFollow up'); expect(callbacks.onContextConsumed).toHaveBeenCalledOnce();
});
@@ -359,11 +359,11 @@ describe('persistent local browser chat', () => {
it('refreshes one system source from file to literal selection to a different file without accumulating code', async () => {
const { props, runtime } = fixture();
await render({ ...props, readerContext: reader('WHOLE_FIRST_FILE') });
await click('Download & load'); await type('Explain the file'); await click('Send ↑');
await click('Download & load'); await type('Which values does the file add?'); await click('Send ↑');
await render({ ...props, readerContext: reader(selection.text, 'selection') });
await type('Explain the marked code'); await click('Send ↑');
await type('Which values does the marked code add?'); await click('Send ↑');
await render({ ...props, readerContext: reader('WHOLE_SECOND_FILE', 'file', 'src/other.ts') });
await type('Explain this other file'); await click('Send ↑');
await type('Which values does this other file add?'); await click('Send ↑');
const requests = runtime.chat.mock.calls.map(call => call[0]);
expect(requests[0][0].content).toContain('WHOLE_FIRST_FILE');
expect(requests[1][0].content).toContain(selection.text);
@@ -372,8 +372,8 @@ describe('persistent local browser chat', () => {
expect(requests[2].map(message => message.content).join('\n')).not.toContain('WHOLE_FIRST_FILE');
expect(requests[2].map(message => message.content).join('\n')).not.toContain(selection.text);
// The marked code is in the same file and keeps the conversation; another file starts a new topic (K17).
expect(requests[1].filter(message => message.role === 'user').map(message => message.content)).toEqual(['Explain the file', 'Answer in English.\n\nExplain the marked code']);
expect(requests[2].filter(message => message.role === 'user').map(message => message.content)).toEqual(['Answer in English.\n\nExplain this other file']);
expect(requests[1].filter(message => message.role === 'user').map(message => message.content)).toEqual(['Which values does the file add?', 'Answer in English.\n\nWhich values does the marked code add?']);
expect(requests[2].filter(message => message.role === 'user').map(message => message.content)).toEqual(['Answer in English.\n\nWhich values does this other file add?']);
expect(props.onAttachmentConsumed).not.toHaveBeenCalled();
expect(container.querySelectorAll('.cbm-chat-answer .cbm-chat-attachment')).toHaveLength(3);
await render(props); await type('Ask from another workspace'); await click('Send ↑');
@@ -385,7 +385,7 @@ describe('persistent local browser chat', () => {
const { props, runtime } = fixture(); const count = deferred<number>();
runtime.countTokens.mockReturnValueOnce(count.promise);
const original = reader('ORIGINAL_LITERAL\r\n\t ', 'selection');
await render({ ...props, readerContext: original }); await click('Download & load'); await type('Explain'); await click('Send ↑');
await render({ ...props, readerContext: original }); await click('Download & load'); await type('How is it computed?'); await click('Send ↑');
const counted = runtime.countTokens.mock.calls[0][0];
original.source!.text = 'MUTATED_AFTER_COUNT';
await render({ ...props, readerContext: reader('NEW_CURRENT_FILE', 'file', 'new.ts') });
@@ -400,7 +400,7 @@ describe('persistent local browser chat', () => {
it('retries an automatic request exactly while newer source is loading', async () => {
const { props, runtime } = fixture(); runtime.chat.mockRejectedValueOnce(new Error('GPU interrupted'));
await render({ ...props, readerContext: reader('ORIGINAL_RETRY_SOURCE') }); await click('Download & load'); await type('Explain'); await click('Send ↑');
await render({ ...props, readerContext: reader('ORIGINAL_RETRY_SOURCE') }); await click('Download & load'); await type('How is it computed?'); await click('Send ↑');
const request = runtime.chat.mock.calls[0][0];
await render({ ...props, readerContext: { project: 'sample', path: 'loading.ts', status: 'loading' } });
await type('New question');
@@ -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 () => {
@@ -1051,9 +1053,11 @@ describe('graph answers and answer limits', () => {
expect(request.map(message => message.role)).toEqual(['system', 'user']);
expect(request[1].content).toContain('Who calls JSONBAgg?');
expect(request[1].content).toContain('Incoming relationships: 23 from 12 symbols.');
expect(container.querySelectorAll('.cbm-chat-turn')).toHaveLength(1);
expect(container.querySelector('.cbm-chat-answer-text')?.textContent).toBe('Adds the two values.');
expect(button('Retry')).toBeDefined();
// The listed answer stays; the model's answer is a turn of its own below it (B1).
expect(container.querySelectorAll('.cbm-chat-turn')).toHaveLength(2);
expect(container.querySelector('.cbm-chat-answer-text')?.textContent).toContain('not generated by the model');
expect([...container.querySelectorAll('.cbm-chat-answer-text')].at(-1)?.textContent).toBe('Adds the two values.');
expect(button('Ask again')).toBeDefined();
});
// 40 callers with long names in their own files: the snapshot names 24, a default prompt fewer.
@@ -1088,7 +1092,7 @@ describe('graph answers and answer limits', () => {
options?.onComplete?.({ stopReason: 'length' }); return 'A long answer that';
});
await render({ ...props, attachment: selection }); await click('Download & load');
await type('Explain this part'); await click('Send ↑');
await type('How is this part computed?'); await click('Send ↑');
const note = container.querySelector<HTMLDetailsElement>('.cbm-chat-turn details.cbm-chat-limit-note');
expect(note?.querySelector('summary')?.textContent).toBe('Token limit reached: the answer was cut short');
expect(note?.textContent).toContain('all 256 output tokens');
@@ -1108,7 +1112,7 @@ describe('graph answers and answer limits', () => {
options?.onComplete?.({ stopReason: 'length' }); return 'A long answer that';
});
await render({ ...props, attachment: selection }); await click('Download & load');
await type('Explain this part'); await click('Send ↑');
await type('How is this part computed?'); await click('Send ↑');
const note = container.querySelector<HTMLDetailsElement>('.cbm-chat-turn details.cbm-chat-limit-note');
expect(note?.textContent).toContain('The output limit is at the maximum of 512 tokens for this model.');
expect([...note?.querySelectorAll('button') ?? []].map(item => item.textContent)).not.toContain('Change the output limit');
@@ -1323,7 +1327,7 @@ describe('agent configuration limits', () => {
await setLimit('cbm-chat-output-tokens', 128); await setLimit('cbm-chat-input-tokens', 1024);
await click('Download & load');
runtime.countTokens.mockResolvedValueOnce(1500);
await type('Explain'); await click('Send ↑');
await type('How is it computed?'); await click('Send ↑');
expect(runtime.chat).not.toHaveBeenCalled();
expect(container.textContent).toMatch(/the local working limit is 1\D?024\./);
await click('Send ↑');
@@ -1587,15 +1591,13 @@ describe('general questions, prompts without a question and configuration files
expect(runtime.chat).not.toHaveBeenCalled();
expect(answer()).toContain('No question was recognized in "test". You can ask, for example:');
for (const example of ['What does JSONBAgg do?', 'Who calls JSONBAgg?', 'What does JSONBAgg call?']) expect(answer()).toContain(example);
expect(buttons()).toEqual(['Ask the model']);
// The model only echoed such a prompt, so the hint offers no model (B5).
expect(buttons()).toEqual([]);
await type('hallo'); await click('Send ↑');
expect(answer()).toContain('In "hallo" wurde keine Frage erkannt. Frage zum Beispiel:');
expect(answer()).toContain('In "hallo" wurde keine Frage erkannt.');
for (const example of ['Was macht JSONBAgg?', 'Wer ruft JSONBAgg auf?']) expect(answer()).toContain(example);
expect(buttons()).toEqual(['Modell fragen']);
expect(buttons()).toEqual([]);
expect(runtime.chat).not.toHaveBeenCalled();
await act(async () => button('Modell fragen').click());
expect(runtime.chat).toHaveBeenCalledOnce();
expect(runtime.chat.mock.calls[0][0].at(-1)!.content).toContain('User question:\nhallo');
// A follow-up does not carry the hint as an answer.
await render({ ...props, readerContext: reader('repos:\n - repo: x\n', 'file', '.pre-commit-config.yaml') });
await type('?'); await click('Send ↑');
@@ -1609,7 +1611,7 @@ describe('general questions, prompts without a question and configuration files
await render({ ...props, selectionScope: 'django-demo:explore', readerContext: reader(text, 'file', '.pre-commit-config.yaml') }); await click('Download & load');
for (const question of ['was macht diese datei?', 'erklär mir die datei detailliert']) {
await type(question); await click('Send ↑');
expect(answer()).toContain('.pre-commit-config.yaml: YAML-Konfiguration, 9 Zeilen. Aus der Datei gelesen:');
expect(answer()).toContain('.pre-commit-config.yaml: YAML-Konfiguration, 9 Zeilen.\npre-commit-Konfiguration: Hooks, die vor jedem Commit laufen.');
expect(answer()).toContain('repos (Liste mit 2 Einträgen):');
expect(answer()).toContain('repo https://github.com/PyCQA/flake8; rev 7.1.1; hooks (1): flake8');
expect(answer()).toContain('Aus der Datei gelesen, nicht vom Modell erzeugt.');
@@ -1617,7 +1619,7 @@ describe('general questions, prompts without a question and configuration files
}
expect(runtime.chat).not.toHaveBeenCalled();
await type('What does this file do?'); await click('Send ↑');
expect(answer()).toContain('.pre-commit-config.yaml: YAML configuration, 9 lines. Read from the file:');
expect(answer()).toContain('.pre-commit-config.yaml: YAML configuration, 9 lines.\npre-commit configuration: hooks that run before each commit.');
expect(runtime.chat).not.toHaveBeenCalled();
await act(async () => button('Ask the model').click());
expect(runtime.chat).toHaveBeenCalledOnce();
@@ -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?');
});
});
+83 -43
View File
@@ -3,23 +3,24 @@ 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';
import { carriedSource, selectionSubject, SYMBOL_SOURCE_LINES, sourceTargetOf, symbolSource, type SymbolSourceReader } from './symbol-source';
import { codeFacts, codeFactsMarkdown, codeSourceFacts } from './code-facts';
import { relationshipAnswer, relationshipSuggestion } from './relationship-answer';
import { clampTokenLimits, tokenLimitBounds, tokenLimitsFor, useAgentPreferences, type TokenLimits } from './agent-preferences';
import { browserChatText, groundedText, relationshipWords } from './strings';
import { browserChatText, chatRound3Text, groundedText, relationshipWords } from './strings';
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';
@@ -56,7 +57,13 @@ type ChatTurn = BrowserChatTurn & { evidence?: PreparedExplanationContext };
/** `grounded`: the facts are listed in the card, so the prompt's name budget is not the reader's limit. */
type Explanation = { key: string; label: string; answer: string; status: string; error?: string; packet?: PreparedExplanationContext; citation?: ReturnType<typeof citedInterpretation>; mode?: 'interpretation'; shortened?: boolean; limit?: TokenLimits; evidence?: string; grounded?: boolean;
/** The facts of a configuration or text file, which the model was not asked about (K12). */
askable?: boolean };
askable?: boolean;
/** The answer is the model's text alone, without listed facts: it says so (B1). */
generated?: boolean };
/** The shape of an explanation for a short general question: facts listed from the graph or read
* from an open code file or marked code, the lines of the selected code, one checked sentence (C5, B3). */
type Grounded = { summary: string[]; code?: string; subject?: { name: string; kind?: string } }
& ({ from: 'graph'; graph: BrowserChatContext; reader?: undefined } | { from: 'file'; reader: BrowserChatReaderContext; graph?: undefined });
/** Finished explanations per selection, so returning to one does not run the model again. */
const EXPLANATION_CACHE_SIZE = 32;
const initialModel = BROWSER_MODELS.find(model => model.availability === 'available')!;
@@ -64,21 +71,26 @@ const cachedInBrowser = (modelId: string) => isBrowserModelCached(getBrowserMode
const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error);
/** The names a Galaxy selection goes by: its label and its symbols with their qualified names. */
const galaxyNames = (galaxy: GalaxyEvidence): string[] => [galaxy.label, ...galaxy.roots.flatMap(root => [root.name, root.qualifiedName ?? ''])].filter(Boolean);
/** Answers the chat gives itself, which "Ask the model" sends to the model as the question they answer. */
const ASKABLE = new Set(['graph', 'suggestion', 'grounded', 'file', 'hint']);
/** Answers the chat gives itself, which "Ask the model" sends to the model as the question they answer.
* Not the hint to a prompt without a question: the model only echoed "test" or "hallo" (B5). */
const ASKABLE = new Set(['graph', 'suggestion', 'grounded', 'file']);
const sizeLabel = (bytes: number): string => bytes >= 1_000_000_000 ? `${(bytes / 1_000_000_000).toFixed(2)} GB` : `${Math.ceil(bytes / 1_000_000)} MB`;
/** Symbol sources read for explanations and questions, newest last. */
const SOURCE_CACHE_SIZE = 32;
/** The listed facts first, then the model's sentence if it stood the check, then who wrote what (K7).
* The facts come from the indexed graph, or for an open workflow from the file itself (K12). The
* answer to a general question says it in the language of that question (C5). */
function groundedExplanation(summary: readonly string[], sentence: string | undefined, dropped: boolean, from: 'graph' | 'file' = 'graph', language: 'en' | 'de' = 'en'): string {
const words = groundedText[language];
const note = from === 'file' ? sentence ? browserChatText.fileFactsAndSentence : dropped ? browserChatText.fileSentenceDropped : browserChatText.fileFactsOnly
: sentence ? words.factsAndSentence : dropped ? words.sentenceDropped : words.factsOnly;
return [summary.map(line => `- ${line}`).join('\n'), ...sentence ? [sentence] : [], `_${note}_`].join('\n\n');
/** The listed facts first, then the lines read from the selected code, then the model's sentence
* if it stood the check, then who wrote what (K7). The facts come from the indexed graph, or for
* an open workflow from the file itself (K12). The answer to a general question says it in the
* language of that question (C5). `code` says what the code declares when the sentence is
* dropped, as it was for JSONBAgg every time (B2). */
function groundedExplanation(summary: readonly string[], sentence: string | undefined, dropped: boolean, from: 'graph' | 'file' = 'graph', language: 'en' | 'de' = 'en', code?: string): string {
const words = from === 'file' ? chatRound3Text[language].fileNotes : groundedText[language];
const note = sentence ? words.factsAndSentence : dropped ? words.sentenceDropped : words.factsOnly;
return [summary.map(line => `- ${line}`).join('\n'), ...code ? [code] : [], ...sentence ? [sentence] : [], `_${note}_`].join('\n\n');
}
/** The same while the model writes its sentence. */
const writingExplanation = (summary: readonly string[], note: string, code?: string): string =>
summary.length ? [summary.map(line => `- ${line}`).join('\n'), ...code ? [code] : [], `_${note}_`].join('\n\n') : '';
function Attachment({ attachment, label }: { attachment: BrowserChatAttachment; label?: string }): JSX.Element {
return <details className="cbm-chat-attachment">
@@ -414,6 +426,9 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
return;
}
const subject = snapshot.reader ? undefined : selectionSubject(snapshot.graph);
// What the selected symbol's code declares stands under the facts, whatever the model writes (B2).
const facts = summary.length && symbol && subject ? codeFacts(symbol, subject) : undefined;
const code = facts ? codeFactsMarkdown(facts, 'en') : undefined;
let request = explanationMessages(packet, subject);
let count = await currentRuntime.countTokens(request);
if (!valid()) return;
@@ -427,7 +442,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
setExplanation({ key: snapshot.key, label: snapshot.label, answer: '', status: 'error', packet, error: !packet.evidence.length ? 'No source or graph evidence is available for this selection.' : 'This selection is too large for the local agent. Select a smaller code range and try again.' });
return;
}
setExplanation({ key: snapshot.key, label: snapshot.label, answer: writing, status: 'generating', packet });
setExplanation({ key: snapshot.key, label: snapshot.label, answer: code ? writingExplanation(summary, browserChatText.writingSentence, code) : writing, status: 'generating', packet });
setPhase('generating');
// Keep an explanation together and ignore output from superseded selections.
let shortened = false;
@@ -439,12 +454,12 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
// One sentence beside the facts (two for reader code), and none that names what the evidence lacks.
const checked = result.status === 'generated' ? explanationSentence(result.markdown, packet, request.map(message => message.content).join('\n')) : {};
if (summary.length || (result.status === 'generated' && checked.dropped)) {
const markdown = summary.length ? groundedExplanation(summary, checked.sentence, checked.dropped !== undefined, from) : `_${browserChatText.explanationDropped}_`;
const markdown = summary.length ? groundedExplanation(summary, checked.sentence, checked.dropped !== undefined, from, 'en', code) : `_${browserChatText.explanationDropped}_`;
const complete: Explanation = { key: snapshot.key, label: snapshot.label, answer: markdown, status: 'complete', mode: 'interpretation', packet, grounded: summary.length > 0,
...shortened && checked.sentence ? { shortened, limit: { inputTokens: autoInput, outputTokens: autoOutput } } : {}, ...snapshot.evidence ? { evidence: snapshot.evidence } : {} };
remember(complete); setExplanation(complete);
} else if (result.status === 'generated') {
const complete: Explanation = { key: snapshot.key, label: snapshot.label, answer: checked.sentence ?? result.markdown, status: 'complete', mode: 'interpretation', packet, citation: result.citation,
const complete: Explanation = { key: snapshot.key, label: snapshot.label, answer: checked.sentence ?? result.markdown, status: 'complete', mode: 'interpretation', packet, citation: result.citation, generated: true,
...shortened ? { shortened, limit: { inputTokens: autoInput, outputTokens: autoOutput } } : {}, ...snapshot.evidence ? { evidence: snapshot.evidence } : {} };
remember(complete); setExplanation(complete);
} else setExplanation({ key: snapshot.key, label: snapshot.label, answer: '', status: 'error', packet, error: result.reason });
@@ -643,11 +658,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;
}
@@ -661,11 +681,19 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
return;
}
// A short general question about a Galaxy selection gets the shape of its explanation: the
// 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).
// listed facts and one checked model sentence, in the language of the question (C5). So
// does one about an open code file or marked code, with the facts read from it (B3).
const codeSource: BrowserChatSource | undefined = !retry && !extra.length && !galaxy
? fileSource ? !isDataFile(fileSource.path) && !fileSource.partial ? fileSource : undefined : source ? { ...source, kind: 'selection' } : undefined : undefined;
const codeFound = codeSource ? codeSourceFacts(codeSource, language) : undefined;
const grounded: Grounded | undefined = !retry && !source && !extra.length && galaxy && generalQuestion(prompt, galaxyNames(galaxy)) === 'general'
? { from: 'graph', graph: currentGraph[0], summary: selectionSummary(currentGraph[0], language), subject: selectionSubject(currentGraph[0]) }
: codeSource && codeFound && generalQuestion(prompt, [codeSource.path, ...codeFound.names]) === 'general'
? { from: 'file', reader: { project: codeSource.project, path: codeSource.path, status: 'ready', source: codeSource }, summary: codeFound.summary, code: codeFound.code } : undefined;
const groundedPacket = (budget: number, symbol?: BrowserChatSource) => grounded?.from === 'file' ? prepareExplanationContext(grounded.reader, [], budget)
: prepareExplanationContext(undefined, packetGraph, budget, symbol);
// 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));
@@ -677,13 +705,16 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
if (manualRequest.current === queued) manualRequest.current = undefined;
return;
}
if (grounded) packet = prepareExplanationContext(undefined, packetGraph, 3200, symbol);
if (grounded) packet = groundedPacket(3200, symbol);
else if ((!retry || askFrom) && ((reader?.source?.text.length ?? 0) > 5000 || packetGraph.length)) packet = prepareExplanationContext(reader, packetGraph, chatEvidence, symbol);
// What the selected symbol's code declares, whatever the model's sentence says (B2).
const declared = grounded?.from === 'graph' && symbol && grounded.subject ? codeFacts(symbol, grounded.subject) : undefined;
const code = grounded?.code ?? (declared ? codeFactsMarkdown(declared, language) : undefined);
// Without source the model could only restate the facts: they are the answer (K7).
if (grounded && explanationMode(packet!) === 'graph') {
if (manualRequest.current === queued) manualRequest.current = undefined;
setTurns(previous => [...previous, { id: `local-turn-${crypto.randomUUID()}`, prompt, context: extra, topic, listedFrom: grounded.graph, replyLanguage: language, evidence: packet,
modelId: model.id, request: [], answer: groundedExplanation(grounded.summary, undefined, false, 'graph', language), status: 'complete', answeredFrom: 'grounded' }]);
setTurns(previous => [...previous, { id: `local-turn-${crypto.randomUUID()}`, prompt, context: extra, topic, ...grounded.graph ? { listedFrom: grounded.graph } : {}, replyLanguage: language, evidence: packet,
modelId: model.id, request: [], answer: groundedExplanation(grounded.summary, undefined, false, grounded.from, language), status: 'complete', answeredFrom: 'grounded' }]);
consume();
return;
}
@@ -707,7 +738,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
const limit = grounded ? autoInput : Math.min(limits.inputTokens, model.contextTokens - limits.outputTokens);
// The sentence of a general question is bounded like that of an automatic explanation.
for (let budget = 1900; grounded && count > limit && budget >= 300; budget = Math.floor(budget * .6)) {
packet = prepareExplanationContext(undefined, packetGraph, budget, symbol);
packet = groundedPacket(budget, symbol);
request = explanationMessages(packet, grounded.subject, language); count = await currentRuntime.countTokens(request);
if (epoch.current !== ticket || stopRequested.current) return;
}
@@ -727,16 +758,19 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
setError(`This prompt needs ${count.toLocaleString()} input tokens; the local working limit is ${limit.toLocaleString()}. Select less code or start a new conversation. Nothing was sent or shortened without disclosure.`);
return;
}
const id = retry?.id ?? `local-turn-${crypto.randomUUID()}`;
// The model's answer to a listed, grounded or file answer is a turn of its own below it:
// the facts stay where they were (B1). Asking again repeats a model answer in place.
const id = retry && !ask ? retry.id : `local-turn-${crypto.randomUUID()}`;
const historyOmitted = retry && !ask ? retry.historyOmitted
: earlier.filter(item => item.status !== 'error' && item.status !== 'generating' && !history.includes(item)).length;
// A general question shows its facts at once; the model only adds a sentence.
const writing = grounded ? `${grounded.summary.map(line => `- ${line}`).join('\n')}\n\n_${groundedText[language].writingSentence}_` : '';
const writing = grounded ? writingExplanation(grounded.summary, groundedText[language].writingSentence, code) : '';
const turn: ChatTurn = { id, prompt, attachment: source, readerContext: reader, context: extra, evidence: packet, modelId: model.id, request, answer: writing, status: 'generating', topic,
...historyOmitted ? { historyOmitted } : {}, ...grounded ? { answeredFrom: 'grounded' as const, listedFrom: grounded.graph, replyLanguage: language } : {} };
...historyOmitted ? { historyOmitted } : {}, ...grounded ? { answeredFrom: 'grounded' as const, ...grounded.graph ? { listedFrom: grounded.graph } : {}, replyLanguage: language } : {},
...ask || retry?.askedModel ? { askedModel: true } : {} };
activeTurn.current = id;
if (retry) setTurns(previous => previous.map(item => item.id === id ? turn : item));
else { setTurns(previous => [...previous, turn]); consume(); }
if (retry && !ask) setTurns(previous => previous.map(item => item.id === id ? turn : item));
else { setTurns(previous => [...previous, turn]); if (!retry) consume(); }
setPhase('generating');
let shortened = false;
const onComplete = ({ stopReason }: { stopReason: string }) => { shortened = stopReason === 'length'; };
@@ -749,7 +783,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
// One sentence beside the facts, checked like the sentence of the explanation card.
const result = parseExplanationResponse(output, packet!);
const checked = !stopRequested.current && result.status === 'generated' ? explanationSentence(result.markdown, packet!, request.map(message => message.content).join('\n')) : {};
const answer = groundedExplanation(grounded.summary, checked.sentence, checked.dropped !== undefined, 'graph', language);
const answer = groundedExplanation(grounded.summary, checked.sentence, checked.dropped !== undefined, grounded.from, language, code);
setTurns(previous => previous.map(item => item.id === id ? { ...item, answer, status: stopRequested.current ? 'stopped' : 'complete' } : item));
return;
}
@@ -761,7 +795,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
const explanation = messageOf(failure);
const id = activeTurn.current;
if (id) setTurns(previous => previous.map(turn => turn.id === id ? { ...turn, status: stopRequested.current ? 'stopped' : 'error', error: stopRequested.current ? undefined : explanation,
...grounded ? { answer: groundedExplanation(grounded.summary, undefined, false, 'graph', language) } : {} } : turn));
...grounded ? { answer: groundedExplanation(grounded.summary, undefined, false, grounded.from, language, code) } : {} } : turn));
else if (!stopRequested.current) setError(explanation);
} finally {
if (manualRequest.current === queued) manualRequest.current = undefined;
@@ -862,6 +896,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
</SourceDisclosure>
{explanation?.key === selected.key ? <>
<ChatMarkdown text={explanation.answer || (explanation.status === 'generating' ? 'Explaining selection…' : explanation.status === 'stopped' ? 'Explanation stopped.' : '')} />
{explanation.generated && explanation.status === 'complete' && <small className="cbm-chat-model-note">{chatRound3Text.en.modelNote}</small>}
{explanation.status !== 'generating' && <AnswerNotes shortened={limitNote(explanation.shortened, explanation.limit, true)} packet={explanation.grounded ? undefined : explanation.packet} model={model.displayName} />}
{explanation.error && <p className="cbm-chat-turn-error" role="alert">{explanation.error}</p>}
{explanation.status !== 'generating' && <button type="button" className="cbm-chat-retry" disabled={phase !== 'ready' || !!selected.waiting} onClick={() => {
@@ -872,10 +907,14 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
: manualRequest.current ? 'This selection will be explained after your answer.' : 'Preparing explanation…'}</p>}
</section>}
{turns.length === 0 && !(proactive && automatic && selected) && <div className="cbm-chat-empty"><span aria-hidden="true">⌁</span><h3>Ask about the code.</h3><p>{readerContext ? 'The current file is included automatically. Mark code to focus your next message on that exact selection.' : 'Ask a question, or add source and graph context to your next message.'}</p></div>}
{turns.map((turn, index) => { const replyWords = relationshipWords[turn.replyLanguage === 'de' ? 'de' : 'en']; 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>}
<div className="cbm-chat-question"><span className="cbm-chat-speaker">You</span><ChatMarkdown text={turn.prompt} /></div>
{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 && 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>
{turn.evidence || turn.attachment || turn.readerContext?.source || turn.context?.length ? <>
{turn.evidence ? <PacketSource packet={turn.evidence} /> : <>
@@ -886,12 +925,13 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
</> : null}
</SourceDisclosure><div className="cbm-chat-answer-text"><ChatMarkdown text={turn.answer || (turn.status === 'generating' ? 'Thinking…' : turn.status === 'stopped' ? 'Stopped before an answer.' : '')} /></div>
{turn.status === 'stopped' && turn.answer && <small>Stopped · partial answer</small>}
{!turn.answeredFrom && turn.answer && (turn.status === 'complete' || turn.status === 'stopped') && <small className="cbm-chat-model-note">{ownWords.modelNote}</small>}
{turn.status !== 'generating' && !turn.answeredFrom && <AnswerNotes shortened={limitNote(turn.shortened, turn.limit)} packet={turn.evidence}
unsupported={turn.answer ? namesNotIn(turn.answer, turn.request.map(message => message.content).join('\n')) : []} model={BROWSER_MODELS.find(candidate => candidate.id === turn.modelId)?.displayName ?? turn.modelId} historyOmitted={turn.historyOmitted} />}
{turn.status === 'error' && <p className="cbm-chat-turn-error" role="alert">{turn.error}</p>}
{index === turns.length - 1 && turn.answeredFrom === 'suggestion' && turn.suggestion && <button type="button" className="cbm-chat-retry"
onClick={() => showSuggestedList(turn)}>{replyWords.showList}</button>}
{index === turns.length - 1 && turn.status !== 'generating' && turn.answeredFrom !== 'local' && <button type="button" className="cbm-chat-retry" disabled={phase !== 'ready'} onClick={() => { void send(turn); }}>{turn.answeredFrom ? replyWords.askModel : 'Retry'}</button>}
{index === turns.length - 1 && turn.status !== 'generating' && (!turn.answeredFrom || ASKABLE.has(turn.answeredFrom)) && <button type="button" className="cbm-chat-retry" disabled={phase !== 'ready'} onClick={() => { void send(turn); }}>{turn.answeredFrom ? replyWords.askModel : turn.status === 'error' ? 'Retry' : ownWords.askAgain}</button>}
</div>
</article>; })}
</div>}
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const css = readFileSync(new URL('./browser-chat.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
const rule = (selector: string) => [...css.matchAll(/([^{}]+)\{([^}]*)\}/g)].filter(match => match[1].split(',').map(item => item.trim()).includes(selector)).map(match => match[2]).join(';');
/** The lines read from JSONBAgg ended mid-template at the panel edge, behind a hidden scrollbar (B2). */
describe('code blocks in a chat answer', () => {
it('wrap long lines instead of clipping them at the panel edge', () => {
expect(rule('.cbm-chat-markdown .cbm-chat-code-block pre')).toMatch(/white-space:\s*pre-wrap/);
expect(rule('.cbm-chat-markdown .cbm-chat-code-block pre')).toMatch(/overflow-wrap:\s*anywhere/);
expect(rule('.cbm-chat-code-block pre > code')).not.toMatch(/width:\s*max-content/);
});
});
+7 -2
View File
@@ -99,8 +99,10 @@
.cbm-chat-code-header > div { display: flex; align-items: center; gap: 7px; flex-shrink: 0; }
.cbm-chat-dock .cbm-chat-code-header button { padding: 2px 7px; min-width: 44px; font-size: 10px; background: transparent; }
.cbm-chat-code-copy-error { color: #ffb6bd; font-size: 10px; }
.cbm-chat-markdown .cbm-chat-code-block pre { margin: 0; padding: 11px; max-width: 100%; max-height: 360px; overflow: auto; overscroll-behavior: contain; white-space: pre; tab-size: 4; }
.cbm-chat-code-block pre > code { display: block; width: max-content; min-width: 100%; background: transparent; color: #d0e8d8; font: 11px/1.65 ui-monospace, SFMono-Regular, monospace; }
/* The panel is narrow and macOS hides scrollbars: a long line wraps instead of ending at the
* edge, as in the Source block (K15). The lines read from the code ended mid-template (B2). */
.cbm-chat-markdown .cbm-chat-code-block pre { margin: 0; padding: 11px; max-width: 100%; max-height: 360px; overflow: auto; overscroll-behavior: contain; white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 4; }
.cbm-chat-code-block pre > code { display: block; min-width: 100%; background: transparent; color: #d0e8d8; font: 11px/1.65 ui-monospace, SFMono-Regular, monospace; }
.cbm-chat-table-scroll { max-width: 100%; overflow-x: auto; }
.cbm-chat-markdown table { width: 100%; border-collapse: collapse; font-size: 12px; }
.cbm-chat-markdown :is(th, td) { padding: 7px 9px; border: 1px solid var(--chat-border); vertical-align: top; text-align: left; }
@@ -128,3 +130,6 @@
.cbm-chat-dock .cbm-chat-new { padding: 4px 9px; font-size: 11px; color: #d0e8d8; }
.cbm-chat-topic-break { display: flex; align-items: center; gap: 8px; margin: 0 0 10px !important; color: var(--chat-muted); font-size: 10px; }
.cbm-chat-topic-break::before, .cbm-chat-topic-break::after { content: ""; flex: 1 1 16px; border-top: 1px solid var(--chat-border); }
/* Who wrote an answer reads like the note under a listed answer: quiet, not a warning. */
.cbm-chat-dock .cbm-chat-model-note { display: block; margin-top: 7px; color: var(--chat-muted); font-size: 11px; font-style: italic; }
.cbm-chat-dock .cbm-chat-asked-model { display: block; margin-top: 6px; color: var(--chat-muted); font-size: 10px; }
@@ -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';
}
@@ -0,0 +1,80 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, vi } from 'vitest';
import BrowserChatDock, { type BrowserChatDockProps, type BrowserChatReaderContext } from './BrowserChatDock';
import type { BrowserAiProgress, BrowserChatMessage } from './browser-ai-runtime';
import type { BrowserChatOptions } from './browser-ai-controller';
import type { SymbolSnippet } from './symbol-source';
/** The chat dock in jsdom with a fake model, for tests that drive it like a reader:
* type, send, click the buttons under an answer and read what the model was sent. */
/** JSONBAgg as get_code_snippet returns it from django-demo. */
export const JSONB_AGG_SNIPPET: SymbolSnippet = { source: 'class JSONBAgg(OrderableAggMixin, Aggregate):\n function = "JSONB_AGG"\n'
+ ' template = "%(function)s(%(distinct)s%(expressions)s %(order_by)s)"\n allow_distinct = True\n output_field = JSONField()\n',
file_path: '/abs/django/contrib/postgres/aggregates/general.py', start_line: 50, end_line: 54, source_mode: 'full' };
/** An open file or marked code in Explore. */
export const readerOf = (text: string, path: string, kind: 'file' | 'selection' = 'file', lines?: { start: number; end: number }): BrowserChatReaderContext => {
const count = text.split('\n').length;
return { project: 'sample', path, status: 'ready', source: { id: `reader-${path}-${kind}`, text, path, kind, project: 'sample', startLine: lines?.start ?? 1, startColumn: 1,
endLine: lines?.end ?? count, endColumn: 1, sourceVersion: 'sha256:1' } };
};
export function dockHarness() {
let container: HTMLDivElement;
let root: Root;
let props: BrowserChatDockProps;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
window.localStorage.clear();
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); });
const runtime = () => ({
prepare: vi.fn(async (_progress: (value: BrowserAiProgress) => void, _options?: { cacheOnly?: boolean }) => {}),
explain: vi.fn(async () => 'Legacy'),
countTokens: vi.fn(async (_messages: readonly BrowserChatMessage[]) => 100),
chat: vi.fn(async (_messages: readonly BrowserChatMessage[], _onToken: (chunk: string) => void, _options?: BrowserChatOptions) => 'Adds the two values.'),
stop: vi.fn(), dispose: vi.fn(),
});
/** A dock with a fake model that is loaded at once; `readSource` returns JSONBAgg. */
const setup = () => {
const model = runtime();
const readSource = vi.fn(async () => JSONB_AGG_SNIPPET);
const base: BrowserChatDockProps = { proactive: false, open: true, onClose: vi.fn(), onAttachmentConsumed: vi.fn(), onAttachmentRemoved: vi.fn(),
createRuntime: vi.fn(() => model), removeCache: vi.fn(async () => {}), readSource };
return { runtime: model, props: base, readSource };
};
const render = async (next: BrowserChatDockProps) => { props = next; await act(async () => root.render(<BrowserChatDock {...next} />)); };
const button = (label: string, scope: ParentNode = document.body) => {
const found = [...scope.querySelectorAll('button')].find(item => item.textContent === label || item.getAttribute('aria-label') === label);
expect(found, `button ${label}`).toBeDefined();
return found!;
};
const click = async (label: string, scope?: ParentNode) => { await act(async () => button(label, scope).click()); };
/** Opens the agent configuration and loads the fake model. */
const load = async () => {
await render({ ...props, settingsRequest: (props.settingsRequest ?? 0) + 1 });
await click('Download & load');
};
const type = async (value: string) => {
const input = container.querySelector('textarea')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
};
const ask = async (question: string) => { await type(question); await click('Send ↑'); };
const turns = () => [...container.querySelectorAll('.cbm-chat-turn')];
const last = () => turns().at(-1)!;
const answerOf = (turn: Element) => turn.querySelector('.cbm-chat-answer-text')?.textContent ?? '';
const buttonsOf = (turn: Element) => [...turn.querySelectorAll('button.cbm-chat-retry')].map(item => item.textContent);
const notesOf = (turn: Element) => [...turn.querySelectorAll('.cbm-chat-answer-note, .cbm-chat-model-note')].map(item => item.textContent ?? '');
const dividers = () => [...container.querySelectorAll('.cbm-chat-topic-break')].map(item => item.textContent);
const card = () => container.querySelector('[aria-label="Current selection explanation"]');
/** The user messages of a request, each by its last line (the question). */
const questionsIn = (request: readonly BrowserChatMessage[]) => request.filter(message => message.role === 'user').map(message => message.content.split('\n').at(-1));
return { setup, render, load, click, button, type, ask, turns, last, answerOf, buttonsOf, notesOf, dividers, card, questionsIn, container: () => container };
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { buildChatMessages, historyAnswer, type BrowserChatTurn } from './chat-model';
const turn = (answer: string, extra: Partial<BrowserChatTurn> = {}): BrowserChatTurn => ({ id: 't', prompt: 'was macht dieser code?', modelId: 'm', request: [], answer, status: 'complete', ...extra });
const GROUNDED = '- Markierte Zeilen 50 bis 54 von `general.py` (Python-Quelltext).\n\nIm Quelltext:\n\n```python\nclass JSONBAgg(OrderableAggMixin, Aggregate):\n function = "JSONB_AGG"\n```\n\n'
+ '+2 weitere Attribute und Methoden\n\nJSONBAgg setzt `function` auf "JSONB_AGG".\n\n_Fakten aus der Datei gelesen; der Text danach ist vom Modell erzeugt._';
/** After four grounded answers "Explain the marked code line by line" got the first fact line
* back from the model; the chat's own notes and code lines are not the model's to repeat (B3). */
describe('answers the chat gave itself in the history of a later request (B3)', () => {
it('sends a grounded answer as its model sentence, or its facts when the sentence was left out', () => {
expect(historyAnswer(turn(GROUNDED, { answeredFrom: 'grounded' }))).toBe('JSONBAgg setzt `function` auf "JSONB_AGG".');
const dropped = GROUNDED.replace('JSONBAgg setzt `function` auf "JSONB_AGG".\n\n', '');
expect(historyAnswer(turn(dropped, { answeredFrom: 'grounded' }))).toBe('- Markierte Zeilen 50 bis 54 von `general.py` (Python-Quelltext).');
});
it('sends a listed answer with its list and a file outline with its heading and purpose, never the note', () => {
expect(historyAnswer(turn('Callers of JSONBAgg in the loaded graph.\n\n- CALLS (11): a, b\n\n_Listed from the indexed graph; not generated by the model._', { answeredFrom: 'graph' })))
.toBe('Callers of JSONBAgg in the loaded graph.\n\n- CALLS (11): a, b');
expect(historyAnswer(turn('`tox.ini`: INI configuration, 89 lines.\n\ntox configuration: the test environments.\n\n- `[tox]`: minversion `4.0`\n- `[testenv]`: deps `x`\n\n_Read from the file; not generated by the model._',
{ answeredFrom: 'file' }))).toBe('`tox.ini`: INI configuration, 89 lines.\n\ntox configuration: the test environments.');
});
it('sends a model answer as it was written, code included', () => {
const written = 'It sets:\n\n```python\nfunction = "JSONB_AGG"\n```\n\n_emphasis the model wrote_';
expect(historyAnswer(turn(written))).toBe(written);
const request = buildChatMessages([turn(GROUNDED, { answeredFrom: 'grounded' }), turn(written)], 'und?');
expect(request.filter(message => message.role === 'assistant').map(message => message.content)).toEqual(['JSONBAgg setzt `function` auf "JSONB_AGG".', written]);
});
});
+31 -3
View File
@@ -2,7 +2,7 @@ import type { BrowserChatMessage } from './browser-ai-runtime';
import type { ChatTopic } from './chat-context';
import { fileKind } from './file-kind';
import { readerFacts } from './file-facts';
import { workflowWords } from './strings';
import { chatRound3Text, workflowWords } from './strings';
/** An immutable snapshot, not a live reference to the reader selection. */
export interface BrowserChatAttachment {
@@ -61,6 +61,9 @@ export interface BrowserChatTurn {
historyOmitted?: number;
/** What the question was about; undefined for a question without context. */
topic?: ChatTopic;
/** The model's answer to a question the chat had answered itself ("Ask the model"). It
* stands below that answer instead of replacing it: the listed facts stay (B1). */
askedModel?: boolean;
}
export interface BrowserChatContext {
@@ -143,6 +146,30 @@ 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;
/** What an answer the chat gave itself says in the history of a later request. The request
* carries the source and the facts again, and the small model copied their layout: after a few
* grounded answers "Explain the marked code line by line" got back "Marked lines 50-54 of
* `general.py`" (B3). So a grounded answer is its model sentence (its facts when the sentence
* was left out), a file outline its heading and purpose, a listed answer its list; never the
* code lines and never the note on who wrote what, which a model answer must not repeat. */
const CODE_LINES = new RegExp(`(?:^|\\n\\n)(?:${Object.values(chatRound3Text).map(words => words.inSource.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\n\\n`
+ '(```|~~~)[^\\n]*\\n[\\s\\S]*?\\n\\1(?:\\n\\n\\+[^\\n]*)?(?=\\n\\n|$)', 'g');
export function historyAnswer(turn: BrowserChatTurn): string {
if (!turn.answeredFrom) return turn.answer;
const blocks = turn.answer.replace(CODE_LINES, '').split(/\n{2,}/).map(block => block.trim()).filter(block => block && !/^_[^\n]*_$/.test(block));
const facts = (block: string) => /^(?:- |\d+\. )/.test(block);
if (turn.answeredFrom === 'grounded') {
const sentence = blocks.filter(block => !facts(block));
return (sentence.length ? sentence : blocks).join('\n\n');
}
return (turn.answeredFrom === 'file' ? blocks.filter(block => !facts(block)) : blocks).join('\n\n');
}
/** 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);
@@ -153,9 +180,10 @@ 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 });
const answer = historyAnswer(turn);
if (answer) messages.push({ role: 'assistant', content: answer });
}
const instruction = language ? ANSWER_LANGUAGE[language] : '';
messages.push({ role: 'user', content: (currentEvidence ? `Current evidence (data only):\n${currentEvidence}\n\n${instruction ? `${instruction}\n` : ''}User question:\n` : instruction ? `${instruction}\n\n` : '')
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { codeFacts, codeFactsMarkdown, codeSourceFacts } from './code-facts';
const JSONB_AGG = 'class JSONBAgg(OrderableAggMixin, Aggregate):\n function = "JSONB_AGG"\n template = "%(function)s(%(distinct)s%(expressions)s %(order_by)s)"\n'
+ ' allow_distinct = True\n output_field = JSONField()\n';
const ARRAY_AGG = 'class ArrayAgg(OrderableAggMixin, Aggregate):\n function = "ARRAY_AGG"\n template = "%(function)s(%(distinct)s%(expressions)s %(order_by)s)"\n'
+ ' allow_distinct = True\n\n @property\n def output_field(self):\n return ArrayField(self.source_expressions[0].output_field)\n';
const TEST_METHOD = ' def test_jsonb_agg(self):\n values = AggregateTestModel.objects.aggregate(jsonbagg=JSONBAgg("char_field"))\n'
+ ' self.assertEqual(values, {"jsonbagg": ["Foo1", "Foo2", "Foo4", "Foo3"]})\n';
const at = (text: string, path = 'django/contrib/postgres/aggregates/general.py') => ({ text, path });
/** "was macht diese klasse?" got four lines of counts: the model's sentence was always dropped,
* and nothing said what the code declares (B2). */
describe('facts read from the selected code (B2)', () => {
it('gives a class its definition line with bases and its class-level assignments', () => {
expect(codeFacts(at(JSONB_AGG), { name: 'JSONBAgg', kind: 'Class' })).toEqual({ fence: 'python', more: 0, unit: 'members', lines: [
'class JSONBAgg(OrderableAggMixin, Aggregate):', ' function = "JSONB_AGG"', ' template = "%(function)s(%(distinct)s%(expressions)s %(order_by)s)"',
' allow_distinct = True', ' output_field = JSONField()'] });
});
it('adds the methods of a class after its assignments, with their decorators, and bounds the lines', () => {
expect(codeFacts(at(ARRAY_AGG), { name: 'ArrayAgg', kind: 'Class' })?.lines).toEqual(['class ArrayAgg(OrderableAggMixin, Aggregate):', ' function = "ARRAY_AGG"',
' template = "%(function)s(%(distinct)s%(expressions)s %(order_by)s)"', ' allow_distinct = True', ' @property', ' def output_field(self):']);
const big = `class Big(Base):\n """A big class."""\n${Array.from({ length: 9 }, (_, index) => ` field_${index} = ${index}`).join('\n')}\n\n def run(self):\n pass\n`;
expect(codeFacts(at(big), { name: 'Big', kind: 'Class' })).toEqual({ fence: 'python', more: 6, unit: 'members',
lines: ['class Big(Base):', ' """A big class."""', ' field_0 = 0', ' field_1 = 1', ' field_2 = 2', ' field_3 = 3'] });
});
it('gives a short method its signature and body, without the indent of its class', () => {
expect(codeFacts(at(TEST_METHOD, 'tests/postgres_tests/test_aggregates.py'), { name: 'test_jsonb_agg', kind: 'Method' })).toEqual({ fence: 'python', more: 0, unit: 'lines', lines: [
'def test_jsonb_agg(self):', ' values = AggregateTestModel.objects.aggregate(jsonbagg=JSONBAgg("char_field"))',
' self.assertEqual(values, {"jsonbagg": ["Foo1", "Foo2", "Foo4", "Foo3"]})'] });
});
it('gives a longer function its signature, joined over lines, and the first line of its docstring', () => {
const long = 'def execute_from_command_line(\n argv=None,\n):\n """\n Run a ManagementUtility.\n\n More text.\n """\n'
+ `${Array.from({ length: 6 }, (_, index) => ` step_${index}()`).join('\n')}\n`;
expect(codeFacts(at(long, 'django/core/management/__init__.py'), { name: 'execute_from_command_line', kind: 'Function' })).toEqual({ fence: 'python', more: 6, unit: 'lines',
lines: ['def execute_from_command_line(argv=None):', ' """', ' Run a ManagementUtility.'] });
});
it('gives code in other languages at least its definition line', () => {
const ts = '/** Adds. */\nexport function sum(a: number, b: number): number {\n return a + b;\n}\n';
expect(codeFacts(at(ts, 'src/sum.ts'), { name: 'sum', kind: 'Function' })).toEqual({ fence: 'typescript', more: 0, unit: 'lines', lines: ['export function sum(a: number, b: number): number'] });
expect(codeFacts(at('', 'src/sum.ts'), { name: 'sum' })).toBeUndefined();
});
it('describes an open code file and marked code by what is read from them (B3)', () => {
const file = { text: `"""\nPostgreSQL aggregates.\n"""\n\n${JSONB_AGG}\n\n${ARRAY_AGG}\ndef helper():\n pass\n`, path: 'django/contrib/postgres/aggregates/general.py', kind: 'file' as const, startLine: 1, endLine: 20 };
expect(codeSourceFacts(file, 'en')).toEqual({ names: ['JSONBAgg', 'ArrayAgg', 'helper'], summary: ['`general.py`: Python source file, 22 lines.',
'Module docstring: "PostgreSQL aggregates."', 'Top-level definitions (3): `class JSONBAgg`, `class ArrayAgg`, `def helper`.'] });
const marked = { ...file, kind: 'selection' as const, text: TEST_METHOD, path: 'tests/postgres_tests/test_aggregates.py', startLine: 200, endLine: 202 };
const one = codeSourceFacts(marked, 'de');
expect(one.summary).toEqual(['Markierte Zeilen 200 bis 202 von `test_aggregates.py` (Python-Quelltext).']);
expect(one.code).toContain('Im Quelltext:\n\n```python\ndef test_jsonb_agg(self):');
const two = codeSourceFacts({ ...marked, text: `${TEST_METHOD}\n def test_other(self):\n pass\n`, startLine: 200, endLine: 205 }, 'en');
expect(two).toEqual({ names: ['test_jsonb_agg', 'test_other'], summary: ['Marked lines 200-205 of `test_aggregates.py` (Python source file).',
'Definitions in the marked code (2): `def test_jsonb_agg`, `def test_other`.'] });
expect(codeSourceFacts({ ...marked, text: 'a + b', path: 'src/sum.ts', startLine: 3, endLine: 3 }, 'en').summary).toEqual(['Marked line 3 of `sum.ts` (TypeScript source file).']);
});
it('writes the facts as a code block under "In the source:" in the language of the question', () => {
const facts = codeFacts(at(JSONB_AGG), { name: 'JSONBAgg', kind: 'Class' })!;
expect(codeFactsMarkdown(facts, 'en')).toBe(`In the source:\n\n\`\`\`python\n${facts.lines.join('\n')}\n\`\`\``);
expect(codeFactsMarkdown({ ...facts, more: 2 }, 'de')).toBe(`Im Quelltext:\n\n\`\`\`python\n${facts.lines.join('\n')}\n\`\`\`\n\n+2 weitere Attribute und Methoden`);
expect(codeFactsMarkdown({ ...facts, unit: 'lines', more: 6 }, 'en').endsWith('+6 more lines')).toBe(true);
});
});
+190
View File
@@ -0,0 +1,190 @@
import { chatRound3Text } from './strings';
/** What the selected code declares, read from its text and never written by the model (B2).
*
* "was macht diese klasse?" about JSONBAgg got four lines of counts: the model's sentence named
* "Arrays" and was dropped every time, and nothing said what the class declares. These lines say
* it: for a class its definition with its bases, its class-level assignments and then its
* methods; for a function its signature, the first line of its docstring and, when it is short,
* its body. Python is read by its indentation; other languages give their definition line. The
* lines are the source's own, joined only where a signature runs over several lines. */
/** At most this many lines; the rest is counted. */
export const CODE_FACT_LINES = 6;
/** A function body up to this many lines is shown whole. */
const SHORT_BODY = 4;
/** Longer lines end in "…". */
const LINE = 120;
export interface CodeFacts {
lines: string[];
/** Members (`members`) or body lines (`lines`) that did not fit. */
more: number;
unit: 'members' | 'lines';
/** The language of the Markdown code block. */
fence: string;
}
const FENCES: Readonly<Record<string, string>> = { py: 'python', pyi: 'python', ts: 'typescript', tsx: 'typescript', js: 'javascript', mjs: 'javascript', jsx: 'javascript',
go: 'go', rs: 'rust', java: 'java', c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', rb: 'ruby', sh: 'bash', kt: 'kotlin', cs: 'csharp', php: 'php', swift: 'swift' };
const extension = (path: string) => (path.split('/').pop() ?? path).replace(/-tpl$/, '').split('.').slice(1).pop()?.toLowerCase() ?? '';
const indentOf = (line: string) => line.length - line.trimStart().length;
const bounded = (line: string) => line.length > LINE ? `${line.slice(0, LINE - 1)}…` : line;
const escape = (name: string) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Brackets still open after `text`, outside strings. */
function openBrackets(text: string): number {
let depth = 0, quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const character = text[index];
if (quote) { if (character === '\\') index++; else if (character === quote) quote = undefined; continue; }
if (character === '#') break;
if (character === '"' || character === "'") quote = character;
else if ('([{'.includes(character)) depth++;
else if (')]}'.includes(character)) depth--;
}
return depth;
}
/** The statement starting at `start`, joined over the lines its brackets keep open. */
function statement(lines: readonly string[], start: number): { text: string; end: number } {
let text = lines[start].trim(), end = start;
while (openBrackets(text) > 0 && end + 1 < lines.length && end - start < 8) text += ` ${lines[++end].trim()}`;
return { text: text.replace(/([([{])\s+/g, '$1').replace(/\s+([)\]}])/g, '$1').replace(/,([)\]}])/g, '$1'), end };
}
/** The first line of a docstring right after line `after`, as it is written (one or two lines). */
function docstring(lines: readonly string[], after: number, indent: string): string[] {
const at = lines.findIndex((line, index) => index > after && line.trim());
const first = at >= 0 ? lines[at].trim() : '';
if (!/^[rubRUB]{0,2}("""|''')/.test(first)) return [];
const bare = /^[rubRUB]{0,2}("""|''')$/.test(first);
const next = bare ? lines.slice(at + 1).find(line => line.trim())?.trim() : undefined;
return bare && next ? [`${indent}${first}`, `${indent}${bounded(next)}`] : [`${indent}${bounded(first)}`];
}
/** Where a docstring after line `after` ends, or `after` without one. */
function docstringEnd(lines: readonly string[], after: number): number {
const at = lines.findIndex((line, index) => index > after && line.trim());
const match = at >= 0 ? /^[rubRUB]{0,2}("""|''')/.exec(lines[at].trim()) : null;
if (!match) return after;
const rest = lines[at].trim().slice(match[0].length);
if (rest.includes(match[1])) return at;
const close = lines.findIndex((line, index) => index > at && line.includes(match[1]));
return close >= 0 ? close : lines.length - 1;
}
function pythonFacts(lines: readonly string[], name?: string): CodeFacts | undefined {
const definition = new RegExp(`^\\s*(?:async\\s+)?(?:def|class)\\s+${name ? `${escape(name)}\\b` : '\\w'}`);
let start = lines.findIndex(line => definition.test(line));
if (start < 0) start = lines.findIndex(line => /^\s*(?:async\s+)?(?:def|class)\s+\w/.test(line));
if (start < 0) return undefined;
const base = indentOf(lines[start]);
const shift = (line: string) => line.slice(Math.min(base, indentOf(line)));
const header = statement(lines, start);
const isClass = /^class\s/.test(header.text);
const inner = lines.slice(header.end + 1).find(line => line.trim() && !line.trim().startsWith('#'));
const pad = ' '.repeat(Math.max(4, inner ? indentOf(inner) - base : 4));
const out = [bounded(header.text), ...docstring(lines, header.end, pad)];
const bodyStart = docstringEnd(lines, header.end) + 1;
const body = lines.slice(bodyStart).filter(line => line.trim() && !line.trim().startsWith('#') && indentOf(line) > base);
if (!isClass) {
const shown = body.length <= SHORT_BODY ? body.map(line => bounded(shift(line).trimEnd())) : [];
const room = Math.max(0, CODE_FACT_LINES - out.length);
return { lines: [...out, ...shown.slice(0, room)], more: body.length - Math.min(shown.length, room), unit: 'lines', fence: 'python' };
}
// Members at the class's own body indent: assignments first, then methods with their decorators.
const memberIndent = inner ? indentOf(inner) : base + 4;
const assignments: string[][] = [], methods: string[][] = [];
let decorators: string[] = [];
for (let index = bodyStart; index < lines.length; index++) {
const line = lines[index];
if (!line.trim() || line.trim().startsWith('#')) continue;
if (indentOf(line) <= base) break;
if (indentOf(line) !== memberIndent) continue;
const { text, end } = statement(lines, index);
if (text.startsWith('@')) { decorators.push(`${pad}${bounded(text)}`); index = end; continue; }
if (/^(?:async\s+)?(?:def|class)\s/.test(text)) methods.push([...decorators, `${pad}${bounded(text)}`]);
else if (/^[A-Za-z_]\w*(?:\s*:[^=]+)?\s*=[^=]/.test(text)) assignments.push([`${pad}${bounded(text)}`]);
decorators = [];
index = end;
}
let more = 0;
for (const member of [...assignments, ...methods]) {
if (out.length + member.length <= CODE_FACT_LINES) out.push(...member); else more++;
}
return { lines: out, more, unit: 'members', fence: 'python' };
}
/** The line that declares `name`, or the first line of code; without its opening brace. */
function definitionLine(lines: readonly string[], name: string | undefined, fence: string): CodeFacts | undefined {
const code = lines.filter(line => line.trim() && !/^\s*(?:\/\/|\/\*|\*|#|--|<!--)/.test(line));
const named = name ? code.find(line => new RegExp(`(?:^|[^\\w$])${escape(name)}(?:[^\\w$]|$)`).test(line)) : undefined;
const line = (named ?? code[0])?.trim().replace(/\s*\{\s*$/, '');
return line ? { lines: [bounded(line)], more: 0, unit: 'lines', fence } : undefined;
}
/** The facts of the code a selection stands for, or undefined without code. */
export function codeFacts(source: { text: string; path: string }, subject?: { name: string; kind?: string }): CodeFacts | undefined {
const lines = source.text.replace(/\r\n?/g, '\n').split('\n');
if (!source.text.trim()) return undefined;
const fence = FENCES[extension(source.path)] ?? '';
return (fence === 'python' ? pythonFacts(lines, subject?.name) : undefined) ?? definitionLine(lines, subject?.name, fence);
}
const LANGUAGES: Readonly<Record<string, string>> = { python: 'Python', typescript: 'TypeScript', javascript: 'JavaScript', go: 'Go', rust: 'Rust', java: 'Java', c: 'C', cpp: 'C++',
ruby: 'Ruby', bash: 'Shell', kotlin: 'Kotlin', csharp: 'C#', php: 'PHP', swift: 'Swift' };
/** At most this many definitions are named; the rest is counted. */
const DEFINITIONS = 8;
const PYTHON_DEFINITION = /^(?:async\s+)?(def|class)\s+([A-Za-z_]\w*)/;
const DEFINITION = /^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(function\*?|class|interface|type|enum|struct|trait|fn|func|const|let|var)\s+([A-Za-z_$][\w$]*)/;
/** The definitions at the outermost indent of the code: "class ArrayAgg", "def ordered". */
function definitionsOf(lines: readonly string[], fence: string): string[] {
const code = lines.filter(line => line.trim() && !/^\s*(?:#|\/\/|\/\*|\*)/.test(line));
const outer = Math.min(...code.map(indentOf));
return code.filter(line => indentOf(line) === outer).flatMap(line => {
const match = (fence === 'python' ? PYTHON_DEFINITION : DEFINITION).exec(line.trim());
return match ? [`${match[1]} ${match[2]}`] : [];
});
}
/** The first line of a Python module docstring. */
function moduleDocstring(lines: readonly string[]): string | undefined {
const at = lines.findIndex(line => line.trim() && !line.trim().startsWith('#'));
const doc = at >= 0 ? docstring(lines, at - 1, '') : [];
const text = doc.at(-1)?.replace(/^[rubRUB]{0,2}("""|''')/, '').replace(/("""|''')$/, '').trim();
return text || undefined;
}
/** An open code file or marked code as facts read from it, for a short general question: what it
* is and how long, a module's docstring, its definitions, and the code facts of a single marked
* symbol (B3). `names` are the definitions' names, by which a question may ask about it. */
export function codeSourceFacts(source: { text: string; path: string; kind: 'file' | 'selection'; startLine: number; endLine: number }, language: 'en' | 'de'): { summary: string[]; code?: string; names: string[] } {
const words = chatRound3Text[language];
const fence = FENCES[extension(source.path)] ?? '';
const kind = words.codeKind(LANGUAGES[fence] ?? '');
const name = `\`${(source.path.split('/').pop() ?? source.path).replace(/`/g, "'")}\``;
const lines = source.text.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n');
const defined = source.text.trim() ? definitionsOf(lines, fence) : [];
const names = defined.map(item => item.split(' ').pop()!);
const listed = (where: 'file' | 'marked') => defined.length ? [words.definitions(defined.length, defined.slice(0, DEFINITIONS).map(item => `\`${item}\``), where)] : [];
if (source.kind === 'file') {
const doc = fence === 'python' ? moduleDocstring(lines) : undefined;
return { summary: [words.outlineHeading(name, kind, lines.length), ...doc ? [words.moduleDocstring(bounded(doc))] : [], ...listed('file')], names };
}
const marked = words.marked(source.startLine, source.endLine, name, kind);
// One marked class or function: its own lines say what it declares (B2).
const first = lines.find(line => line.trim() && !/^\s*(?:#|\/\/|@)/.test(line));
const single = defined.length === 1 && first !== undefined && (fence === 'python' ? PYTHON_DEFINITION : DEFINITION).test(first.trim());
const facts = single ? codeFacts(source, { name: names[0] }) : undefined;
return { summary: [marked, ...single ? [] : listed('marked')], ...facts ? { code: codeFactsMarkdown(facts, language) } : {}, names };
}
/** "In the source:" with the lines as a code block and what did not fit, in the language of the question. */
export function codeFactsMarkdown(facts: CodeFacts, language: 'en' | 'de'): string {
const words = chatRound3Text[language];
const mark = facts.lines.some(line => line.includes('```')) ? '~~~' : '```';
const more = facts.more ? `\n\n${facts.unit === 'members' ? words.moreMembers(facts.more) : words.moreLines(facts.more)}` : '';
return `${words.inSource}\n\n${mark}${facts.fence}\n${facts.lines.join('\n')}\n${mark}${more}`;
}
+22 -11
View File
@@ -61,21 +61,32 @@ function tomlFacts(text: string): string[] {
...tables.length ? [words.tables(tables.length, tables.slice(0, LISTED).map(table => `${quote(table.name)} (${words.keyCount(table.keys)})`))] : []];
}
/** INI and its relatives: sections with their number of keys, or the keys of a file without sections. */
function iniFacts(text: string): string[] {
const sections: { name: string; keys: number }[] = [];
const loose: string[] = [];
/** One key of an INI file with its value; an indented line below it continues the value. */
export interface IniEntry { key: string; values: string[] }
/** INI and its relatives as they are written: the keys before the first section, then each
* section with its keys. The automatic card counts them, the outline lists them (B6). */
export function iniSections(text: string): { loose: IniEntry[]; sections: { name: string; entries: IniEntry[] }[] } {
const sections: { name: string; entries: IniEntry[] }[] = [];
const loose: IniEntry[] = [];
let last: IniEntry | undefined;
for (const raw of rows(text)) {
const line = raw.trim();
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
const section = /^\[([^\]]+)\]$/.exec(line);
if (section) { sections.push({ name: `[${section[1].trim()}]`, keys: 0 }); continue; }
const key = /^(?:export\s+)?([^=:\s][^=:]*?)\s*[=:]/.exec(line);
if (!key || /^\s/.test(raw)) continue;
if (sections.length) sections[sections.length - 1].keys += 1; else loose.push(key[1]);
if (section) { sections.push({ name: `[${section[1].trim()}]`, entries: [] }); last = undefined; continue; }
if (/^\s/.test(raw)) { last?.values.push(line); continue; }
const key = /^(?:export\s+)?([^=:\s][^=:]*?)\s*[=:]\s*(.*)$/.exec(line);
last = key ? { key: key[1], values: key[2] ? [key[2]] : [] } : undefined;
if (last) (sections.length ? sections[sections.length - 1].entries : loose).push(last);
}
if (sections.length) return [words.sections(sections.length, sections.slice(0, LISTED).map(section => `${quote(section.name)} (${words.keyCount(section.keys)})`))];
return loose.length ? [words.keys(loose.length, loose.slice(0, LISTED).map(quote))] : [];
return { loose, sections };
}
/** INI and its relatives: sections with their number of keys, or the keys of a file without sections. */
function iniFacts(text: string): string[] {
const { loose, sections } = iniSections(text);
if (sections.length) return [words.sections(sections.length, sections.slice(0, LISTED).map(section => `${quote(section.name)} (${words.keyCount(section.entries.length)})`))];
return loose.length ? [words.keys(loose.length, loose.slice(0, LISTED).map(entry => quote(entry.key)))] : [];
}
/** Markdown: the title, the second-level sections and the code blocks; lines inside a code block are code. */
@@ -129,7 +140,7 @@ function structureFacts(path: string, text: string): string[] {
case 'yml': case 'yaml': return yamlFacts(text);
case 'json': return jsonFacts(text);
case 'toml': return tomlFacts(text);
case 'ini': case 'cfg': case 'conf': case 'env': case 'properties': case 'editorconfig': return iniFacts(text);
case 'ini': case 'cfg': case 'conf': case 'env': case 'properties': case 'editorconfig': case 'flake8': case 'coveragerc': case 'pylintrc': return iniFacts(text);
case 'md': return markdownFacts(text);
case 'rst': return restructuredFacts(text);
case 'gitignore': case 'dockerignore': case 'gitattributes': return patternFacts(text);
+2
View File
@@ -12,6 +12,8 @@ const KINDS: Readonly<Record<string, string>> = {
conf: 'configuration file, not program code', env: 'environment settings, not program code', properties: 'Java properties, not program code',
editorconfig: 'EditorConfig settings, not program code', gitignore: 'ignore patterns, not program code', dockerignore: 'ignore patterns, not program code',
gitattributes: 'Git attribute patterns, not program code',
// INI files named after their tool, without an extension of their own (B6).
flake8: 'INI configuration, not program code', coveragerc: 'INI configuration, not program code', pylintrc: 'INI configuration, not program code',
};
/** Configuration, data and text files: what they say is read from them, not guessed by a model (K12). */
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { fileOutline } from './file-outline';
import { fileFactLines, isDataFile } from './file-facts';
/** django's tox.ini, shortened to its first sections. */
const TOX = `# Tox (https://tox.readthedocs.io/) is a tool for running tests in multiple
# virtualenvs.
[tox]
minversion = 4.0
skipsdist = true
envlist =
py3
black
flake8
# Add environment to use the default python3 installation
[testenv:py3]
basepython = python3
[testenv]
usedevelop = true
setenv =
PYTHONDONTWRITEBYTECODE=1
deps =
-e .
py{3,310,311,312,313,py3}: -rtests/requirements/py3.txt
postgres: -rtests/requirements/postgres.txt
changedir = tests
commands =
{envpython} runtests.py {posargs}
[testenv:javascript]
usedevelop = false
deps =
changedir = {toxinidir}
`;
const PRE_COMMIT = 'repos:\n - repo: https://github.com/PyCQA/isort\n rev: 5.13.2\n hooks:\n - id: isort\n - repo: https://github.com/PyCQA/flake8\n rev: 7.1.1\n hooks:\n - id: flake8\n';
const FLAKE8 = '[flake8]\nexclude = build,.git,.tox,./tests/.env\nextend-ignore = E203\nmax-line-length = 88\nper-file-ignores =\n django/core/cache/backends/filebased.py:W601\n tests/cache/tests.py:W601\n';
const EDITORCONFIG = '# https://editorconfig.org/\n\nroot = true\n\n[*]\nindent_style = space\nindent_size = 4\n\n[*.py]\nmax_line_length = 88\n\n[Makefile]\nindent_style = tab\n';
/** "was macht diese datei?" on tox.ini went to the model, which wrote "Testverwendungen" (B6). */
describe('the outline of an INI file, read from it (B6)', () => {
it('lists each section of tox.ini with its keys and short values', () => {
const outline = fileOutline('tox.ini', TOX, 'en')!;
expect(outline).toContain('- `[tox]`: minversion `4.0`; skipsdist `true`; envlist: `py3`, `black`, `flake8`');
expect(outline).toContain('- `[testenv:py3]`: basepython `python3`');
expect(outline).toContain('- `[testenv]`: usedevelop `true`; setenv `PYTHONDONTWRITEBYTECODE=1`; deps: `-e .`, `py{3,310,311,312,313,py3}: -rtests/requirements/py3.txt`, '
+ '`postgres: -rtests/requirements/postgres.txt`; changedir `tests`; commands `{envpython} runtests.py {posargs}`');
expect(outline).toContain('- `[testenv:javascript]`: usedevelop `false`; deps empty; changedir `{toxinidir}`');
expect(outline).not.toContain('virtualenvs');
expect(outline).toContain('_Read from the file; not generated by the model._');
expect(fileOutline('tox.ini', TOX, 'de')).toContain('- `[testenv:javascript]`: usedevelop `false`; deps leer; changedir `{toxinidir}`');
});
it('reads .flake8, .editorconfig, setup.cfg, pytest.ini and .coveragerc the same way, keys before the first section included', () => {
expect(fileOutline('.flake8', FLAKE8, 'en')).toContain('- `[flake8]`: exclude `build,.git,.tox,./tests/.env`; extend-ignore `E203`; max-line-length `88`; '
+ 'per-file-ignores: `django/core/cache/backends/filebased.py:W601`, `tests/cache/tests.py:W601`');
const editor = fileOutline('.editorconfig', EDITORCONFIG, 'en')!;
expect(editor).toContain('- `root`: `true`');
expect(editor).toContain('- `[*]`: indent_style `space`; indent_size `4`');
expect(editor).toContain('- `[Makefile]`: indent_style `tab`');
expect(fileOutline('setup.cfg', '[metadata]\nname = demo\n\n[isort]\nprofile = black\n', 'en')).toContain('- `[isort]`: profile `black`');
expect(fileOutline('pytest.ini', '[pytest]\naddopts = -ra\n', 'en')).toContain('- `[pytest]`: addopts `-ra`');
expect(fileOutline('.coveragerc', '[run]\nbranch = True\nsource = django\n', 'en')).toContain('- `[run]`: branch `True`; source `django`');
});
it('bounds a section with many keys and a file with many sections with "+N more"', () => {
const wide = `[many]\n${Array.from({ length: 11 }, (_, index) => `key${index} = ${index}`).join('\n')}\n`;
expect(fileOutline('wide.ini', wide, 'en')).toContain('key7 `7`; +3 more');
const sections = Array.from({ length: 15 }, (_, index) => `[section${index}]\nkey = ${index}\n`).join('\n');
expect(fileOutline('many.cfg', sections, 'de')).toContain('+3 weitere');
});
it('counts the sections of .flake8 and .coveragerc for the automatic card like those of any INI file', () => {
expect(isDataFile('.flake8')).toBe(true);
expect(isDataFile('.coveragerc')).toBe(true);
expect(fileFactLines('.flake8', FLAKE8)).toContain('Sections (1): `[flake8]` (4 keys).');
expect(fileFactLines('tox.ini', TOX)).toContain('Sections (4): `[tox]` (3 keys), `[testenv:py3]` (1 key), `[testenv]` (5 keys), `[testenv:javascript]` (3 keys).');
});
});
/** The outline named what the file holds but not what it is for, and said "Read from the file" twice (B7). */
describe('the purpose of a well-known file before its outline (B7)', () => {
it('says what a pre-commit file is for, and "Read from the file" only in the note', () => {
const english = fileOutline('.pre-commit-config.yaml', PRE_COMMIT, 'en')!;
expect(english.split('\n\n').slice(0, 2)).toEqual(['`.pre-commit-config.yaml`: YAML configuration, 9 lines.', 'pre-commit configuration: hooks that run before each commit.']);
expect(english.match(/Read from the file/g)).toHaveLength(1);
const german = fileOutline('.pre-commit-config.yaml', PRE_COMMIT, 'de')!;
expect(german.split('\n\n').slice(0, 2)).toEqual(['`.pre-commit-config.yaml`: YAML-Konfiguration, 9 Zeilen.', 'pre-commit-Konfiguration: Hooks, die vor jedem Commit laufen.']);
expect(german.match(/Aus der Datei gelesen/g)).toHaveLength(1);
});
it.each([
['.github/workflows/new_contributor_pr.yml', 'name: New contributor message\non:\n pull_request_target:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/first-interaction@v1\n',
'GitHub runs the jobs of this workflow when one of its triggers occurs.', 'GitHub führt die Jobs dieses Workflows aus, wenn einer seiner Auslöser eintritt.'],
['package.json', '{"name": "Django", "scripts": {"test": "grunt test"}}', 'npm package manifest: the name, scripts and dependencies of a JavaScript package.',
'npm-Paketmanifest: Name, Skripte und Abhängigkeiten eines JavaScript-Pakets.'],
['pyproject.toml', '[build-system]\nrequires = ["setuptools"]\n\n[project]\nname = "Django"\n', 'Python project configuration: how the package is built, its metadata and the settings of tools.',
'Python-Projektkonfiguration: wie das Paket gebaut wird, seine Metadaten und die Einstellungen von Werkzeugen.'],
['tox.ini', TOX, 'tox configuration: the test environments tox creates and the commands it runs in each.', 'tox-Konfiguration: die Testumgebungen, die tox anlegt, und die Befehle, die es in jeder ausführt.'],
['setup.cfg', '[metadata]\nname = demo\n', 'setuptools configuration: package metadata and options, often also settings of other tools.',
'setuptools-Konfiguration: Paketmetadaten und Optionen, oft auch Einstellungen anderer Werkzeuge.'],
['docker-compose.yml', 'services:\n web:\n image: nginx\n', 'Docker Compose file: the services that Docker Compose starts together.', 'Docker-Compose-Datei: die Services, die Docker Compose zusammen startet.'],
['.readthedocs.yml', 'version: 2\nbuild:\n os: ubuntu-24.04\n', 'Read the Docs configuration: how readthedocs.org builds the documentation.', 'Read-the-Docs-Konfiguration: wie readthedocs.org die Dokumentation baut.'],
['.editorconfig', EDITORCONFIG, 'EditorConfig: indentation, line endings and similar editor settings per file pattern.', 'EditorConfig: Einrückung, Zeilenenden und ähnliche Editor-Einstellungen je Dateimuster.'],
['graph-ui/tsconfig.json', '{"compilerOptions": {"strict": true}, "include": ["src"]}', 'TypeScript compiler configuration: which files are compiled and with which options.',
'TypeScript-Compilerkonfiguration: welche Dateien mit welchen Optionen kompiliert werden.'],
['.flake8', FLAKE8, 'flake8 configuration: the rules of the Python linter flake8.', 'flake8-Konfiguration: die Regeln des Python-Linters flake8.'],
])('gives %s its purpose in both languages', (path, text, english, german) => {
expect(fileOutline(path, text, 'en')!.split('\n\n')[1]).toBe(english);
expect(fileOutline(path, text, 'de')!.split('\n\n')[1]).toBe(german);
});
it('gives an unknown file, or a known name with another structure, no purpose line', () => {
const unknown = fileOutline('config/settings.yaml', 'debug: true\n', 'en')!;
expect(unknown.split('\n\n')).toEqual(['`settings.yaml`: YAML configuration, 1 line.', '- `debug`: `true`', '_Read from the file; not generated by the model._']);
expect(fileOutline('docker-compose.yml', 'version: "3"\n', 'en')!.split('\n\n')[1]).toBe('- `version`: `3`');
expect(fileOutline('.pre-commit-config.yaml', 'default_language_version:\n python: python3\n', 'en')).not.toContain('hooks that run before each commit');
});
});
+2 -2
View File
@@ -34,7 +34,7 @@ const WORKFLOW = 'name: New contributor message\n\non:\n pull_request_target:\n
describe('the outline of a configuration file, read from it (C7)', () => {
it('lists each repo of a pre-commit file with its revision and hooks', () => {
const outline = fileOutline('.pre-commit-config.yaml', PRE_COMMIT, 'en')!;
expect(outline).toContain('`.pre-commit-config.yaml`: YAML configuration, 26 lines. Read from the file:');
expect(outline).toContain('`.pre-commit-config.yaml`: YAML configuration, 26 lines.\n\npre-commit configuration: hooks that run before each commit.');
expect(outline).toContain('- `repos` (list of 5):');
expect(outline).toContain(' 1. repo `https://github.com/psf/black-pre-commit-mirror`; rev `25.1.0`; hooks (1): `black` (exclude `\\.py-tpl$`)');
expect(outline).toContain(' 2. repo `https://github.com/adamchainz/blacken-docs`; rev `1.19.1`; hooks (1): `blacken-docs` '
@@ -45,7 +45,7 @@ describe('the outline of a configuration file, read from it (C7)', () => {
it('writes the outline in German for a German question', () => {
const outline = fileOutline('.pre-commit-config.yaml', PRE_COMMIT, 'de')!;
expect(outline).toContain('`.pre-commit-config.yaml`: YAML-Konfiguration, 26 Zeilen. Aus der Datei gelesen:');
expect(outline).toContain('`.pre-commit-config.yaml`: YAML-Konfiguration, 26 Zeilen.\n\npre-commit-Konfiguration: Hooks, die vor jedem Commit laufen.');
expect(outline).toContain('- `repos` (Liste mit 5 Einträgen):');
expect(outline).toContain(' 4. repo `https://github.com/PyCQA/flake8`; rev `7.1.1`; hooks (1): `flake8`');
expect(outline).toContain('_Aus der Datei gelesen, nicht vom Modell erzeugt._');
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { fileOutline } from './file-outline';
/** django's pyproject.toml, its first table and the start of [project]. */
const PYPROJECT = `[build-system]
requires = ["setuptools>=75.8.1"]
build-backend = "setuptools.build_meta"
[project]
name = "Django"
requires-python = ">= 3.10"
dependencies = [
"asgiref>=3.8.1",
"sqlparse>=0.3.1",
"tzdata; sys_platform == 'win32'",
]
authors = [
{name = "Django Software Foundation", email = "foundation@djangoproject.com"},
]
license = {text = "BSD-3-Clause"}
`;
/** "was macht diese datei?" on pyproject.toml showed "dependencies [; authors [" for arrays over several lines. */
describe('TOML arrays over several lines in the outline', () => {
it('lists the items of a multi-line array, and keeps commas inside an inline table', () => {
const outline = fileOutline('pyproject.toml', PYPROJECT, 'en')!;
expect(outline).toContain('- `[project]`: name `Django`; requires-python `>= 3.10`; dependencies: `asgiref>=3.8.1`, `sqlparse>=0.3.1`, `tzdata; sys_platform == \'win32\'`; '
+ 'authors: `{name = "Django Software Foundation", email = "foundation@djangoproject.com"}`; license `{text = "BSD-3-Clause"}`');
expect(outline).not.toMatch(/dependencies \[|authors \[/);
expect(outline).toContain('- `[build-system]`: requires: `setuptools>=75.8.1`; build-backend `setuptools.build_meta`');
});
});
+85 -14
View File
@@ -1,5 +1,6 @@
import { isGithubWorkflow } from './file-kind';
import { fileOutlineWords, germanWorkflowWords, workflowWords } from './strings';
import { iniSections, type IniEntry } from './file-facts';
import { chatRound3Text, fileOutlineWords, germanWorkflowWords, workflowWords } from './strings';
import { workflowFactLines, yamlTree, type YamlValue } from './workflow-facts';
/** "What does this file do?" about a configuration file, answered from the file (C7). The
@@ -28,17 +29,48 @@ function jsonValue(value: unknown): YamlValue {
return { kind: 'scalar', value: String(value) };
}
/** TOML: the keys before the first table, each `[table]` with its keys, each `[[table]]` as a list item. */
/** Brackets still open after `text`, outside strings and comments. */
function openBrackets(text: string): number {
let depth = 0, quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const character = text[index];
if (quote) { if (character === '\\' && quote === '"') index++; else if (character === quote) quote = undefined; continue; }
if (character === '#') break;
if (character === '"' || character === "'") quote = character;
else if (character === '[' || character === '{') depth++;
else if (character === ']' || character === '}') depth--;
}
return depth;
}
/** The items of an array, split at its own commas, not at those inside strings or inline tables. */
function arrayItems(inner: string): string[] {
const items: string[] = [];
let depth = 0, quote: string | undefined, start = 0;
for (let index = 0; index < inner.length; index++) {
const character = inner[index];
if (quote) { if (character === '\\' && quote === '"') index++; else if (character === quote) quote = undefined; continue; }
if (character === '"' || character === "'") quote = character;
else if (character === '[' || character === '{') depth++;
else if (character === ']' || character === '}') depth--;
else if (character === ',' && depth === 0) { items.push(inner.slice(start, index)); start = index + 1; }
}
return [...items, inner.slice(start)].map(item => item.trim()).filter(Boolean);
}
/** TOML: the keys before the first table, each `[table]` with its keys, each `[[table]]` as a list
* item. An array may run over several lines ("dependencies = [" with one item per line). */
function tomlTree(text: string): YamlValue | undefined {
const root: [string, YamlValue][] = [];
let current = root, quoted: string | undefined;
const unquote = (item: string) => item.replace(/^(["'])(.*)\1$/, '$2');
const value = (raw: string): YamlValue => {
const plain = raw.replace(/\s+#[^"']*$/, '').trim();
if (/^\[.*\]$/.test(plain)) return { kind: 'list', items: plain.slice(1, -1).split(',').map(item => item.trim().replace(/^(["'])(.*)\1$/, '$2')).filter(Boolean).map(item => ({ kind: 'scalar', value: item })) };
return { kind: 'scalar', value: plain.replace(/^(["'])(.*)\1$/, '$2') };
if (/^\[[\s\S]*\]$/.test(plain)) return { kind: 'list', items: arrayItems(plain.slice(1, -1)).map(item => ({ kind: 'scalar', value: unquote(item) })) };
return { kind: 'scalar', value: unquote(plain) };
};
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
const lines = text.split(/\r?\n/);
for (let at = 0; at < lines.length; at++) {
const line = lines[at].trim();
if (quoted) { if (line.split(quoted).length % 2 === 0) quoted = undefined; continue; }
if (!line || line.startsWith('#')) continue;
const table = /^(\[\[?)\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line);
@@ -56,13 +88,30 @@ function tomlTree(text: string): YamlValue | undefined {
if (!key) continue;
const delimiter = ['"""', "'''"].find(mark => line.split(mark).length === 2);
if (delimiter) quoted = delimiter;
current.push([key[1], delimiter ? { kind: 'scalar', value: '' } : value(key[2])]);
let raw = key[2];
// The lines an open array or inline table runs over, without their comments.
while (!delimiter && openBrackets(raw) > 0 && at + 1 < lines.length) raw += ` ${lines[++at].trim().replace(/\s+#[^"']*$/, '')}`;
current.push([key[1], delimiter ? { kind: 'scalar', value: '' } : value(raw)]);
}
return root.length ? { kind: 'map', entries: root } : undefined;
}
/** INI: the keys before the first section, then each `[section]` with its keys. A value
* continued on indented lines is a list of those lines (`deps =` with one per line, B6). */
function iniTree(text: string): YamlValue | undefined {
const { loose, sections } = iniSections(text);
const value = (entry: IniEntry): YamlValue => entry.values.length > 1 ? { kind: 'list', items: entry.values.map(item => ({ kind: 'scalar', value: item })) }
: { kind: 'scalar', value: entry.values[0] ?? '' };
const entries = (list: readonly IniEntry[]) => list.map((entry): [string, YamlValue] => [entry.key, value(entry)]);
const root = [...entries(loose), ...sections.map((section): [string, YamlValue] => [section.name, { kind: 'map', entries: entries(section.entries) }])];
return root.length ? { kind: 'map', entries: root } : undefined;
}
const INI = new Set(['ini', 'cfg', 'flake8', 'coveragerc', 'pylintrc', 'editorconfig']);
function treeOf(path: string, text: string): YamlValue | undefined {
switch (extension(path)) {
const kind = extension(path);
if (INI.has(kind)) return iniTree(text);
switch (kind) {
case 'yml': case 'yaml': return yamlTree(text);
case 'json': try { return jsonValue(JSON.parse(text)); } catch { return undefined; }
case 'toml': return tomlTree(text);
@@ -70,6 +119,26 @@ function treeOf(path: string, text: string): YamlValue | undefined {
}
}
type Purpose = keyof typeof chatRound3Text.en.purposes;
const has = (tree: YamlValue | undefined, test: (key: string) => boolean) => tree?.kind === 'map' && tree.entries.some(([key]) => test(key));
/** What a well-known file is for, by its name, when its structure is that of such a file (B7). */
function purposeOf(path: string, tree: YamlValue | undefined): Purpose | undefined {
const name = (path.split('/').pop() ?? path).toLowerCase();
if (/^\.pre-commit-config\.ya?ml$/.test(name)) return has(tree, key => key === 'repos') ? 'preCommit' : undefined;
if (name === 'package.json') return tree?.kind === 'map' ? 'npmPackage' : undefined;
if (name === 'pyproject.toml') return has(tree, key => /^\[(?:project|build-system|tool\..+)\]$/.test(key)) ? 'pyproject' : undefined;
if (name === 'tox.ini') return has(tree, key => /^\[(?:tox|testenv.*)\]$/.test(key)) ? 'tox' : undefined;
if (name === 'setup.cfg') return tree?.kind === 'map' ? 'setupCfg' : undefined;
if (/^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/.test(name)) return has(tree, key => key === 'services') ? 'compose' : undefined;
if (/^\.readthedocs\.ya?ml$/.test(name)) return has(tree, key => key === 'version' || key === 'build' || key === 'sphinx') ? 'readTheDocs' : undefined;
if (name === '.editorconfig') return has(tree, key => key.startsWith('[') || key === 'root') ? 'editorConfig' : undefined;
if (/^tsconfig(?:\.[\w-]+)?\.json$/.test(name)) return has(tree, key => ['compilerOptions', 'include', 'files', 'extends', 'references'].includes(key)) ? 'tsconfig' : undefined;
if (name === '.flake8') return has(tree, key => key === '[flake8]') ? 'flake8' : undefined;
if (name === 'pytest.ini') return has(tree, key => key === '[pytest]') ? 'pytest' : undefined;
if (name === '.coveragerc') return has(tree, key => key.startsWith('[')) ? 'coverage' : undefined;
return undefined;
}
const isMap = (value: YamlValue): value is Extract<YamlValue, { kind: 'map' }> => value.kind === 'map';
const scalarText = (value: YamlValue, words: Words) => value.kind === 'scalar' ? value.value === '' ? words.empty : /^[|>][-+0-9]*$/.test(value.value) ? words.text : code(value.value) : '';
const more = (total: number, shown: number, words: Words, separator = '; ') => total > shown ? `${separator}${words.more(total - shown)}` : '';
@@ -108,18 +177,20 @@ function entryLines(key: string, value: YamlValue, words: Words): string[] {
...value.items.length > ITEMS ? [` ${words.more(value.items.length - ITEMS)}`] : []];
}
/** The outline of a YAML, JSON or TOML file in the language of the question, or undefined
* for any other file and for text that does not parse. */
/** The outline of a YAML, JSON, TOML or INI file in the language of the question, or undefined
* for any other file and for text that does not parse. A well-known file says first what it
* is for; "Read from the file" stands once, in the note at the end (B7). */
export function fileOutline(path: string, text: string, language: 'en' | 'de'): string | undefined {
const words = fileOutlineWords[language];
const words = fileOutlineWords[language], own = chatRound3Text[language];
const name = code(path.split('/').pop() ?? path);
if (isGithubWorkflow(path)) {
const facts = workflowFactLines(path, text, language === 'de' ? germanWorkflowWords : workflowWords);
if (facts.length) return [words.heading(name, words.kinds.workflow, lineCount(text)), facts.map(line => `- ${line}`).join('\n'), `_${words.note}_`].join('\n\n');
if (facts.length) return [own.outlineHeading(name, words.kinds.workflow, lineCount(text)), own.purposes.workflow, facts.map(line => `- ${line}`).join('\n'), `_${words.note}_`].join('\n\n');
}
const tree = treeOf(path, text);
if (!tree) return undefined;
const kind = extension(path) === 'json' ? words.kinds.json : extension(path) === 'toml' ? words.kinds.toml : words.kinds.yaml;
const kind = extension(path) === 'json' ? words.kinds.json : extension(path) === 'toml' ? words.kinds.toml : INI.has(extension(path)) ? own.iniKind : words.kinds.yaml;
const purpose = purposeOf(path, tree);
const entries: [string, YamlValue][] = tree.kind === 'map' ? tree.entries : [['', tree]];
const lines: string[] = [];
let used = 0, shown = 0;
@@ -130,5 +201,5 @@ export function fileOutline(path: string, text: string, language: 'en' | 'de'):
lines.push(...next); used += size; shown++;
}
if (shown === Math.min(entries.length, ENTRIES) && entries.length > ENTRIES) lines.push(words.more(entries.length - ENTRIES));
return [words.heading(name, kind, lineCount(text)), lines.join('\n'), `_${words.note}_`].join('\n\n');
return [own.outlineHeading(name, kind, lineCount(text)), ...purpose ? [own.purposes[purpose]] : [], lines.join('\n'), `_${words.note}_`].join('\n\n');
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { knownNames, noQuestion } from './question-intent';
import { jsonbAggEvidence } from './galaxy-evidence.fixture';
import { readGalaxyEvidence } from './galaxy-evidence';
/** "test" went to the model whenever the open file contained the word, because every word of
* the file counted as a name the prompt could ask about (B5). */
describe('greetings and probe words are never names of the open file (B5)', () => {
const file = 'import pytest\n\ndef test(): ...\n# hallo hi hello hey moin ok ja nein danke\nclass Aggregate: pass\n';
it('reads "test", "hallo" and the like as prompts without a question, even when the file contains them', () => {
const known = knownNames({ names: ['tests/test_aggregates.py'], texts: [file] });
for (const prompt of ['test', 'hallo', 'hi', 'hello', 'hey', 'moin', 'ok', 'ja', 'nein', 'danke', '?', 'Test', 'hallo test']) expect(noQuestion(prompt, known), prompt).toBe(true);
// Real words of the file still ask about it.
expect(noQuestion('Aggregate', known)).toBe(false);
expect(noQuestion('pytest', known)).toBe(false);
});
it('counts a probe word when it is exactly the name of the selection', () => {
expect(noQuestion('test', knownNames({ names: ['test'], texts: [file] }))).toBe(false);
expect(noQuestion('test', knownNames({ names: ['django-demo.tests.test'], texts: [] }))).toBe(false);
// A longer name that only contains the word does not.
expect(noQuestion('test', knownNames({ names: ['test_jsonb_agg'], texts: [file] }))).toBe(true);
const galaxy = readGalaxyEvidence(jsonbAggEvidence().text)!;
expect(noQuestion('test', knownNames({ galaxy, names: ['JSONBAgg'], texts: [] }))).toBe(true);
});
});
+31 -9
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,25 +96,47 @@ 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
* words of the open file or attached code. A prompt that names one of them is a question. */
* words of the open file or attached code. A prompt that names one of them is a question.
* A greeting or probe ("test", "hallo", "ok") is none of them, even where the file has the
* word, unless it is exactly the selection's own name: "test" went to the model whenever the
* open file contained it (B5). */
export function knownNames(sources: { galaxy?: GalaxyEvidence; texts: readonly string[]; names: readonly string[] }): (word: string) => boolean {
const words = new Set<string>();
const add = (text: string) => { for (const word of text.match(/[\p{L}\p{N}_][\p{L}\p{N}_.-]*/gu) ?? []) words.add(word.toLowerCase()); };
sources.texts.forEach(add);
sources.names.forEach(name => { words.add(name.toLowerCase()); words.add((name.split(/[/.:]/).filter(Boolean).pop() ?? name).toLowerCase()); });
const lastPart = (name: string) => (name.split(/[/.:]/).filter(Boolean).pop() ?? name).toLowerCase();
sources.names.forEach(name => { words.add(name.toLowerCase()); words.add(lastPart(name)); });
const galaxy = sources.galaxy;
if (galaxy) for (const group of [...galaxy.relationships.incoming, ...galaxy.relationships.outgoing]) for (const file of group.files) file.symbols.forEach(symbol => words.add(symbol.name.toLowerCase()));
const own = sources.names.map(name => name.toLowerCase()).filter(name => name.length >= 4);
return word => words.has(word.toLowerCase()) || own.some(name => withinEdits(word.toLowerCase(), name, typoBudget(name)));
const ownNames = new Set(sources.names.flatMap(name => [name.toLowerCase(), lastPart(name)]));
return word => {
const lower = word.toLowerCase();
if (GREETINGS.has(lower)) return ownNames.has(lower);
return words.has(lower) || own.some(name => withinEdits(lower, name, typoBudget(name)));
};
}
+86
View File
@@ -381,3 +381,89 @@ export const fileWords = {
patterns: (total: number, items: readonly string[]) => `${counted(total, 'pattern', 'patterns')}: ${listed(items, total)}.`,
root: (name: string) => `Root element: ${name}.`,
};
/** Third hand test round (B1 to B7): the chat's own words for an answer handed to the model,
* a topic the reader comes back to, a follow-up without context, facts read from code and the
* purpose of well-known files. New strings only; the ones above keep their own wording. */
const englishRound3Text = {
/** Under the question of a turn asked of the model from an answer the chat gave itself (B1). */
askedModel: 'Asked the local model',
/** 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:`,
/** Above the lines read from the selected code (B2). */
inSource: 'In the source:',
moreMembers: (total: number) => `+${total.toLocaleString('en-US')} more ${total === 1 ? 'attribute or method' : 'attributes and methods'}`,
moreLines: (total: number) => `+${counted(total, 'more line', 'more lines')}`,
/** An open code file or marked code as facts read from it, for a short general question (B3). */
codeKind: (language: string) => language ? `${language} source file` : 'source file',
marked: (start: number, end: number, name: string, kind: string) => `${start === end ? `Marked line ${start}` : `Marked lines ${start}-${end}`} of ${name} (${kind}).`,
moduleDocstring: (text: string) => `Module docstring: "${text}"`,
definitions: (total: number, items: readonly string[], where: 'file' | 'marked') =>
`${where === 'file' ? 'Top-level definitions' : 'Definitions in the marked code'} (${total.toLocaleString('en-US')}): ${items.join(', ')}${total > items.length ? `, +${(total - items.length).toLocaleString('en-US')} more` : ''}.`,
/** Who wrote which part of an answer about a file (K12), in the language of the question. */
fileNotes: { factsAndSentence: browserChatText.fileFactsAndSentence, factsOnly: browserChatText.fileFactsOnly, sentenceDropped: browserChatText.fileSentenceDropped },
/** The first line of a file outline; the note under it says where it was read (B7). */
outlineHeading: (name: string, kind: string, lines: number) => `${name}: ${kind}, ${counted(lines, 'line', 'lines')}.`,
iniKind: 'INI configuration',
/** What a well-known file is for, by its name and structure, before its outline (B7). */
purposes: {
preCommit: 'pre-commit configuration: hooks that run before each commit.',
workflow: 'GitHub runs the jobs of this workflow when one of its triggers occurs.',
npmPackage: 'npm package manifest: the name, scripts and dependencies of a JavaScript package.',
pyproject: 'Python project configuration: how the package is built, its metadata and the settings of tools.',
tox: 'tox configuration: the test environments tox creates and the commands it runs in each.',
setupCfg: 'setuptools configuration: package metadata and options, often also settings of other tools.',
compose: 'Docker Compose file: the services that Docker Compose starts together.',
readTheDocs: 'Read the Docs configuration: how readthedocs.org builds the documentation.',
editorConfig: 'EditorConfig: indentation, line endings and similar editor settings per file pattern.',
tsconfig: 'TypeScript compiler configuration: which files are compiled and with which options.',
flake8: 'flake8 configuration: the rules of the Python linter flake8.',
pytest: 'pytest configuration: the options pytest runs the tests with.',
coverage: 'coverage.py configuration: which code test coverage measures and how it is reported.',
},
};
export const chatRound3Text: { en: typeof englishRound3Text; de: typeof englishRound3Text } = {
en: englishRound3Text,
de: {
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:`,
inSource: 'Im Quelltext:',
moreMembers: (total: number) => `+${total.toLocaleString('de-DE')} ${total === 1 ? 'weiteres Attribut oder weitere Methode' : 'weitere Attribute und Methoden'}`,
moreLines: (total: number) => `+${gezaehlt(total, 'weitere Zeile', 'weitere Zeilen')}`,
codeKind: (language: string) => language ? `${language}-Quelltext` : 'Quelltext',
marked: (start: number, end: number, name: string, kind: string) => `${start === end ? `Markierte Zeile ${start}` : `Markierte Zeilen ${start} bis ${end}`} von ${name} (${kind}).`,
moduleDocstring: (text: string) => `Docstring des Moduls: "${text}"`,
definitions: (total: number, items: readonly string[], where: 'file' | 'marked') =>
`${where === 'file' ? 'Definitionen auf oberster Ebene' : 'Definitionen im markierten Code'} (${total.toLocaleString('de-DE')}): ${items.join(', ')}${total > items.length ? `, +${(total - items.length).toLocaleString('de-DE')} weitere` : ''}.`,
fileNotes: {
factsAndSentence: 'Fakten aus der Datei gelesen; der Text danach ist vom Modell erzeugt.',
factsOnly: 'Aus der Datei gelesen, nicht vom Modell erzeugt.',
sentenceDropped: 'Aus der Datei gelesen. Der Text des Modells nannte etwas, das die Datei nicht zeigt, und wurde weggelassen.',
},
outlineHeading: (name: string, kind: string, lines: number) => `${name}: ${kind}, ${gezaehlt(lines, 'Zeile', 'Zeilen')}.`,
iniKind: 'INI-Konfiguration',
purposes: {
preCommit: 'pre-commit-Konfiguration: Hooks, die vor jedem Commit laufen.',
workflow: 'GitHub führt die Jobs dieses Workflows aus, wenn einer seiner Auslöser eintritt.',
npmPackage: 'npm-Paketmanifest: Name, Skripte und Abhängigkeiten eines JavaScript-Pakets.',
pyproject: 'Python-Projektkonfiguration: wie das Paket gebaut wird, seine Metadaten und die Einstellungen von Werkzeugen.',
tox: 'tox-Konfiguration: die Testumgebungen, die tox anlegt, und die Befehle, die es in jeder ausführt.',
setupCfg: 'setuptools-Konfiguration: Paketmetadaten und Optionen, oft auch Einstellungen anderer Werkzeuge.',
compose: 'Docker-Compose-Datei: die Services, die Docker Compose zusammen startet.',
readTheDocs: 'Read-the-Docs-Konfiguration: wie readthedocs.org die Dokumentation baut.',
editorConfig: 'EditorConfig: Einrückung, Zeilenenden und ähnliche Editor-Einstellungen je Dateimuster.',
tsconfig: 'TypeScript-Compilerkonfiguration: welche Dateien mit welchen Optionen kompiliert werden.',
flake8: 'flake8-Konfiguration: die Regeln des Python-Linters flake8.',
pytest: 'pytest-Konfiguration: die Optionen, mit denen pytest die Tests ausführt.',
coverage: 'coverage.py-Konfiguration: welcher Code bei der Testabdeckung gemessen und wie darüber berichtet wird.',
},
},
};
+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);
}