mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
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?');
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -61,12 +61,12 @@ describe('what a question is about', () => {
|
||||
describe('which earlier turns a question carries (K17)', () => {
|
||||
const file = chatTopic('p:explore', { reader: { project: 'p', path: 'a.yml', status: 'ready', source: { ...attachment, path: 'a.yml', kind: 'file' } } })!;
|
||||
const selected = chatTopic('p:galaxy', { graph: jsonbAggEvidence() })!;
|
||||
it('carries only the turns since the topic last changed, as the divider says', () => {
|
||||
it('carries the turns of its own topic, never those of another one', () => {
|
||||
const [first, second] = [turn(file), turn(file)];
|
||||
expect(topicHistory([first, second], file)).toEqual([first, second]);
|
||||
expect(topicHistory([first, turn(selected)], file)).toEqual([]);
|
||||
// Coming back to a file after another topic starts it fresh: the old wrong answer stays out.
|
||||
expect(topicHistory([first, turn(selected), second], file)).toEqual([second]);
|
||||
// Coming back to a file after another topic carries its earlier turns again (B4).
|
||||
expect(topicHistory([first, turn(selected)], file)).toEqual([first]);
|
||||
expect(topicHistory([first, turn(selected), second], file)).toEqual([first, second]);
|
||||
expect(topicHistory([turn(selected), first, second], file)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
|
||||
@@ -21,20 +21,33 @@ export function chatTopic(scope: string, sources: { reader?: BrowserChatReaderCo
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The earlier turns a question about `topic` carries: those since the topic last changed.
|
||||
* A file or selection the reader comes back to starts fresh, as the divider above it says,
|
||||
* so an earlier wrong answer about it is not sent again (K17). Replies without a topic
|
||||
* (no context) neither belong to nor end a topic. */
|
||||
/** The earlier turns a question about `topic` carries: its own, also from before the reader
|
||||
* went to another file or selection and came back, and never those of another topic (K17).
|
||||
* Coming back started fresh, so "Und was noch?" got "Ja, und noch." (B4). Replies without a
|
||||
* topic (no context) belong to none. */
|
||||
export function topicHistory<T extends BrowserChatTurn>(turns: readonly T[], topic: ChatTopic | undefined): T[] {
|
||||
if (!topic) return [];
|
||||
const run: T[] = [];
|
||||
for (let index = turns.length - 1; index >= 0; index--) {
|
||||
const turn = turns[index];
|
||||
if (!turn.topic) continue;
|
||||
if (turn.topic.key !== topic.key) break;
|
||||
run.unshift(turn);
|
||||
}
|
||||
return run;
|
||||
return topic ? turns.filter(turn => turn.topic?.key === topic.key) : [];
|
||||
}
|
||||
|
||||
/** The divider above turn `index`: "new" for the first turn about another topic, "back" for
|
||||
* a topic that has earlier turns, nothing within a topic (B4). */
|
||||
export function topicDivider(turns: readonly BrowserChatTurn[], index: number): 'new' | 'back' | undefined {
|
||||
const topic = turns[index]?.topic;
|
||||
if (!topic || index === 0) return undefined;
|
||||
const earlier = turns.slice(0, index);
|
||||
if ([...earlier].reverse().find(item => item.topic)?.topic?.key === topic.key) return undefined;
|
||||
return earlier.some(item => item.topic?.key === topic.key) ? 'back' : 'new';
|
||||
}
|
||||
|
||||
/** Follow-ups that ask nothing of their own: they continue the answer before (B4). */
|
||||
const FOLLOW_UPS = new Set(['und', 'and', 'mehr', 'more', 'noch mehr', 'und mehr', 'and more', 'mehr davon', 'more of that', 'mehr details', 'more details', 'details',
|
||||
'was noch', 'und was noch', 'was sonst', 'und sonst', 'sonst', 'sonst noch was', 'sonst noch etwas', 'und sonst noch was', 'noch was', 'noch etwas', 'und noch',
|
||||
'weiter', 'und weiter', 'und dann', 'erzähl mehr', 'erzähl mir mehr', 'warum', 'wieso', 'weshalb', 'und warum',
|
||||
'what else', 'and what else', 'anything else', 'else', 'and then', 'then', 'continue', 'go on', 'tell me more', 'why', 'and why', 'how so']);
|
||||
|
||||
/** A short follow-up that needs the turns before it: "und", "mehr", "und was noch?", "and?", "more". */
|
||||
export function contextFreeFollowUp(prompt: string): boolean {
|
||||
return FOLLOW_UPS.has(prompt.toLowerCase().replace(/[?!.,;:…]+/g, ' ').replace(/\s+/g, ' ').trim());
|
||||
}
|
||||
|
||||
/** A question without its own context follows up on explicitly attached code or context
|
||||
@@ -53,8 +66,9 @@ export function followedTopic(turns: readonly BrowserChatTurn[], scope: string):
|
||||
|
||||
/** German when the question reads German; the chat's own replies follow the question.
|
||||
* "was" and "die" are English words too and decide nothing on their own. Short questions
|
||||
* with typos ("was mcht die klasse") and greetings ("hallo") read German too (C4). */
|
||||
const GERMAN = /[äöüß]|\b(?:ich|du|der|das|und|ist|nicht|wie|wer|wo|warum|wieso|weshalb|kannst|kann|mir|mich|diese[rsnm]?|dise[rsnm]?|datei|sagen|erkl\w*|zeig\w*|welche\w*|gibt|wird|macht|mach|mcht|tut|klasse|klase|funktion|methode|modul|hallo|moin|servus|danke|bitte|kurz\w*|antwort\w*|sehr|ein|eine[rsnm]?|sind|noch|auch|hier|zeile\w*|genau|beschreib\w*|detailliert\w*|ausf\w*)\b/i;
|
||||
* with typos ("was mcht die klasse") and greetings ("hallo") read German too (C4), and so do
|
||||
* follow-ups such as "mehr" (B4). */
|
||||
const GERMAN = /[äöüß]|\b(?:ich|du|der|das|und|ist|nicht|wie|wer|wo|warum|wieso|weshalb|kannst|kann|mir|mich|diese[rsnm]?|dise[rsnm]?|datei|sagen|erkl\w*|zeig\w*|welche\w*|gibt|wird|macht|mach|mcht|tut|klasse|klase|funktion|methode|modul|hallo|moin|servus|danke|bitte|kurz\w*|antwort\w*|sehr|ein|eine[rsnm]?|sind|noch|auch|hier|zeile\w*|genau|beschreib\w*|detailliert\w*|ausf\w*|mehr|weiter|sonst|dann)\b/i;
|
||||
export function questionLanguage(prompt: string): 'en' | 'de' {
|
||||
return GERMAN.test(prompt) ? 'de' : 'en';
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
};
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -384,7 +384,7 @@ async function k17(page) {
|
||||
const header1 = await page.locator('.cbm-chat-header .cbm-chat-new').boundingBox();
|
||||
if (header1) await shot(page, 'K17', 'header-crop', 'Crop of the chat header with the New conversation button.', { clip: { x: header1.x - 260, y: header1.y - 14, width: header1.width + 320, height: header1.height + 28 } });
|
||||
const assistantTurns = (request?.messages ?? []).filter((message) => message.role === 'assistant').length;
|
||||
// Back to the same workflow file: its earlier answer is not sent again, as the divider says (review finding).
|
||||
// Back to the same workflow file: "Zurück zu" and its own earlier turn is sent again, nothing of JSONBAgg (B4).
|
||||
await tab(page, 'explore');
|
||||
await page.waitForFunction(() => /New contributor message/.test(document.body.innerText), null, { timeout: 30000 }).catch(() => {});
|
||||
await explanationDone(page);
|
||||
@@ -393,17 +393,17 @@ async function k17(page) {
|
||||
const [back] = await sentSince(page, mark);
|
||||
const backText = promptText(back);
|
||||
const divider = await page.locator('.cbm-chat-topic-break').allInnerTexts();
|
||||
await shot(page, 'K17', 'explore-return', 'Back in Explore on the same file: a divider "New topic: .github/workflows/new_contributor_pr.yml"; the earlier answer is not in the prompt.');
|
||||
await shot(page, 'K17', 'explore-return', 'Back in Explore on the same file: a divider "Zurück zu: .github/workflows/new_contributor_pr.yml"; the earlier question about the file is in the prompt, nothing of JSONBAgg.');
|
||||
const dividerBox = await page.locator('.cbm-chat-topic-break').last().boundingBox();
|
||||
if (dividerBox) await shot(page, 'K17', 'explore-return-crop', 'Crop of the divider above the returned question.', { clip: { x: dividerBox.x - 8, y: Math.max(0, dividerBox.y - 8), width: dividerBox.width + 16, height: 220 } });
|
||||
const backAssistant = (back?.messages ?? []).filter((message) => message.role === 'assistant').length;
|
||||
const backAssistant = (back?.messages ?? []).filter((message) => message.role === 'assistant');
|
||||
const backUsers = (back?.messages ?? []).filter((message) => message.role === 'user').map((message) => message.content.split('\n').at(-1));
|
||||
check('K17', 'Earlier answers about another file are not resent, also not on the way back; topic breaks and New conversation visible', Boolean(request) && !/Welche Aktion nutzt dieses aktuelle File/.test(text)
|
||||
&& !text.includes(exploreAnswer.slice(-60).trim()) && assistantTurns === 0 && /New conversation/.test(header) && divider.some((item) => /New topic: JSONBAgg/.test(item))
|
||||
&& Boolean(back) && backAssistant === 0 && !/Welche Aktion nutzt dieses aktuelle File/.test(backText) && !backText.includes(exploreAnswer.slice(-60).trim())
|
||||
&& JSON.stringify(backUsers) === JSON.stringify(['Und was noch?']) && divider.some((item) => /New topic: \.github\/workflows\/new_contributor_pr\.yml\. Earlier messages are not sent/.test(item)),
|
||||
check('K17', 'Earlier answers about another topic are not sent; coming back to a file says "Zurück zu" and sends that file\'s own earlier turn; topic breaks and New conversation visible', Boolean(request) && !/Welche Aktion nutzt dieses aktuelle File/.test(text)
|
||||
&& assistantTurns === 0 && /New conversation/.test(header) && divider.some((item) => /New topic: JSONBAgg/.test(item))
|
||||
&& Boolean(back) && backAssistant.length === 1 && !/JSONBAgg/.test(backText)
|
||||
&& JSON.stringify(backUsers) === JSON.stringify(['Welche Aktion nutzt dieses aktuelle File?', 'Und was noch?']) && divider.some((item) => /Zurück zu: \.github\/workflows\/new_contributor_pr\.yml\. Die früheren Nachrichten dazu werden wieder mitgeschickt/.test(item)),
|
||||
{ assistantMessagesInPrompt: assistantTurns, flake8InPrompt: /flake8/i.test(text), exploreQuestionInPrompt: /aktuelle File/.test(text), header, divider, exploreAnswer: exploreAnswer.slice(0, 200), galaxyAnswer: galaxyAnswer.slice(0, 200),
|
||||
returnPrompt: { assistantMessages: backAssistant, userMessages: backUsers, earlierExploreAnswerInPrompt: backText.includes(exploreAnswer.slice(-60).trim()) }, returnAnswer: returnAnswer.slice(0, 200) });
|
||||
returnPrompt: { assistantMessages: backAssistant.map((message) => message.content.slice(0, 160)), userMessages: backUsers, jsonbAggInPrompt: /JSONBAgg/.test(backText) }, returnAnswer: returnAnswer.slice(0, 200) });
|
||||
await save('K17', 'prompt-galaxy.txt', text);
|
||||
await save('K17', 'prompt-explore-return.txt', backText);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user