diff --git a/graph-ui/src/browser-ai/BrowserChatDock.code-questions.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.code-questions.test.tsx new file mode 100644 index 000000000..71de0da8f --- /dev/null +++ b/graph-ui/src/browser-ai/BrowserChatDock.code-questions.test.tsx @@ -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(); + }); +}); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index 196973aff..c30f84f91 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -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(); 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(); 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(); 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(); 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('.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('.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 ↑'); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index f33efc161..dd9da493c 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -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; diff --git a/graph-ui/src/browser-ai/code-facts.test.ts b/graph-ui/src/browser-ai/code-facts.test.ts index 49a10acea..ed64e2287 100644 --- a/graph-ui/src/browser-ai/code-facts.test.ts +++ b/graph-ui/src/browser-ai/code-facts.test.ts @@ -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\`\`\``); diff --git a/graph-ui/src/browser-ai/code-facts.ts b/graph-ui/src/browser-ai/code-facts.ts index fbba2dcac..37209e035 100644 --- a/graph-ui/src/browser-ai/code-facts.ts +++ b/graph-ui/src/browser-ai/code-facts.ts @@ -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> = { 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]; diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index a1b6e0a4d..629d5bd81 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -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: {