fix(ui): answer general questions about code files from the file

"was macht diese datei?" about an open Python file in Explore, or "explain
this" about marked code, still got the free model answer, while the same
question about a Galaxy selection got listed facts and one checked
sentence. These questions now get that shape too, in the language of the
question: the file with its kind and length, a module's docstring and its
top level definitions, or the marked lines; for one marked class or
function also the lines of its code. The model adds at most two sentences,
checked like those of the explanation card, and the note says what was
read from the file and what the model wrote.

Questions that ask for detail or anything else still go to the model as
before. Architecture selections keep their English facts. Tests that used
"Explain" only as some question to the model now ask a specific one.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-04 14:52:12 +02:00
1 parent da5751aefc
commit 376e106f65
6 files changed
+184 -32

No files matched your search

@@ -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();
});
});
@@ -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');
@@ -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');
@@ -1092,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');
@@ -1112,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');
@@ -1327,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 ↑');
+27 -16
View File
@@ -8,7 +8,7 @@ import { explanationInput, EXPLANATION_DELAY_MS, type ExplanationInput } from '.
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 } from './code-facts';
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, chatRound3Text, groundedText, relationshipWords } from './strings';
@@ -60,6 +60,10 @@ type Explanation = { key: string; label: string; answer: string; status: string;
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')!;
@@ -80,9 +84,8 @@ const SOURCE_CACHE_SIZE = 32;
* 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 = groundedText[language];
const note = from === 'file' ? sentence ? browserChatText.fileFactsAndSentence : dropped ? browserChatText.fileSentenceDropped : browserChatText.fileFactsOnly
: sentence ? words.factsAndSentence : dropped ? words.sentenceDropped : words.factsOnly;
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. */
@@ -678,9 +681,17 @@ 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;
// 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);
@@ -694,16 +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 && symbol && grounded.subject ? codeFacts(symbol, grounded.subject) : undefined;
const code = declared ? codeFactsMarkdown(declared, language) : undefined;
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;
}
@@ -727,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;
}
@@ -755,7 +766,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
// A general question shows its facts at once; the model only adds a sentence.
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 && !ask) setTurns(previous => previous.map(item => item.id === id ? turn : item));
@@ -772,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, code);
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;
}
@@ -784,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, code) } : {} } : 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;
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { codeFacts, codeFactsMarkdown } from './code-facts';
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';
@@ -45,6 +45,20 @@ describe('facts read from the selected code (B2)', () => {
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\`\`\``);
+49
View File
@@ -132,6 +132,55 @@ export function codeFacts(source: { text: string; path: string }, subject?: { na
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];
+18
View File
@@ -399,6 +399,14 @@ const englishRound3Text = {
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',
@@ -430,6 +438,16 @@ export const chatRound3Text: { en: typeof englishRound3Text; de: typeof englishR
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: {