From a3432f8b8e1d200b25bc5d5f736a23e795979930 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 14:30:04 +0200 Subject: [PATCH 1/9] fix(ui): keep the listed answer when the model is asked about it "Ask the model" under a listed, grounded or file answer put the model's text in place of that turn, so the 11 listed callers became one sentence naming one of them. The model's reply is now a turn of its own below the listed one, with the same question and the line "Asked the local model". Every answer the model wrote freely now says so under it, in the language of the question ("Generated by the local model; it can be wrong."), also on an explanation card without listed facts. The button under such an answer reads "Ask again" instead of "Retry"; after an error it stays "Retry". Signed-off-by: Bernhard Jackiewicz --- .../BrowserChatDock.ask-model.test.tsx | 85 +++++++++++++++++++ .../src/browser-ai/BrowserChatDock.test.tsx | 12 +-- graph-ui/src/browser-ai/BrowserChatDock.tsx | 31 ++++--- graph-ui/src/browser-ai/browser-chat.css | 3 + graph-ui/src/browser-ai/chat-dock.fixture.tsx | 80 +++++++++++++++++ graph-ui/src/browser-ai/chat-model.ts | 3 + graph-ui/src/browser-ai/strings.ts | 19 +++++ 7 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 graph-ui/src/browser-ai/BrowserChatDock.ask-model.test.tsx create mode 100644 graph-ui/src/browser-ai/chat-dock.fixture.tsx diff --git a/graph-ui/src/browser-ai/BrowserChatDock.ask-model.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.ask-model.test.tsx new file mode 100644 index 000000000..6dd383256 --- /dev/null +++ b/graph-ui/src/browser-ai/BrowserChatDock.ask-model.test.tsx @@ -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']); + }); +}); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index a87cee2e7..a37dfd6ac 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -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(); }); @@ -1051,9 +1051,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. diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index 3b10ff2a0..a38594c23 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -10,7 +10,7 @@ import { AUTO_INPUT_TOKENS, AUTO_OUTPUT_TOKENS, citedInterpretation, explanation import { carriedSource, selectionSubject, SYMBOL_SOURCE_LINES, sourceTargetOf, symbolSource, type SymbolSourceReader } from './symbol-source'; 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'; @@ -56,7 +56,9 @@ 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; 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 }; /** 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')!; @@ -444,7 +446,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " ...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 }); @@ -727,16 +729,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 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, 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'; }; @@ -862,6 +867,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " {explanation?.key === selected.key ? <> + {explanation.generated && explanation.status === 'complete' && {chatRound3Text.en.modelNote}} {explanation.status !== 'generating' && } {explanation.error &&

{explanation.error}

} {explanation.status !== 'generating' && } - {index === turns.length - 1 && turn.status !== 'generating' && turn.answeredFrom !== 'local' && } + {index === turns.length - 1 && turn.status !== 'generating' && turn.answeredFrom !== 'local' && } ; })} } diff --git a/graph-ui/src/browser-ai/browser-chat.css b/graph-ui/src/browser-ai/browser-chat.css index 1621b2bd3..3e7ce0b7c 100644 --- a/graph-ui/src/browser-ai/browser-chat.css +++ b/graph-ui/src/browser-ai/browser-chat.css @@ -128,3 +128,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; } diff --git a/graph-ui/src/browser-ai/chat-dock.fixture.tsx b/graph-ui/src/browser-ai/chat-dock.fixture.tsx new file mode 100644 index 000000000..842a6a78a --- /dev/null +++ b/graph-ui/src/browser-ai/chat-dock.fixture.tsx @@ -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).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()); }; + 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 }; +} diff --git a/graph-ui/src/browser-ai/chat-model.ts b/graph-ui/src/browser-ai/chat-model.ts index a2b36e4c5..ed5592e24 100644 --- a/graph-ui/src/browser-ai/chat-model.ts +++ b/graph-ui/src/browser-ai/chat-model.ts @@ -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 { diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index 96f287a49..b389df598 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -381,3 +381,22 @@ 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', +}; +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', + }, +}; From 1b696eaada6862272aaa60c4ece64ed0eb0692a4 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 14:32:21 +0200 Subject: [PATCH 2/9] fix(ui): read "test" as a prompt without a question in any open file Every word of the open file counted as a name the prompt could ask about, so "test" went to the model whenever the file contained the word, and the model echoed it. Greetings and probe words (test, hallo, hi, ok, ja, danke and the like) now never count as names unless one of them is exactly the selection's own name. The hint for such a prompt no longer offers "Ask the model": the model had nothing to answer there. Signed-off-by: Bernhard Jackiewicz --- .../BrowserChatDock.probes.test.tsx | 34 +++++++++++++++++++ .../src/browser-ai/BrowserChatDock.test.tsx | 10 +++--- graph-ui/src/browser-ai/BrowserChatDock.tsx | 7 ++-- .../browser-ai/question-intent.probes.test.ts | 27 +++++++++++++++ graph-ui/src/browser-ai/question-intent.ts | 15 ++++++-- 5 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 graph-ui/src/browser-ai/BrowserChatDock.probes.test.tsx create mode 100644 graph-ui/src/browser-ai/question-intent.probes.test.ts diff --git a/graph-ui/src/browser-ai/BrowserChatDock.probes.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.probes.test.tsx new file mode 100644 index 000000000..1ee747d50 --- /dev/null +++ b/graph-ui/src/browser-ai/BrowserChatDock.probes.test.tsx @@ -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(); + }); +}); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index a37dfd6ac..5d349e140 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -1589,15 +1589,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 ↑'); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index a38594c23..4510a07dc 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -66,8 +66,9 @@ 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; @@ -902,7 +903,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " {turn.status === 'error' &&

{turn.error}

} {index === turns.length - 1 && turn.answeredFrom === 'suggestion' && turn.suggestion && } - {index === turns.length - 1 && turn.status !== 'generating' && turn.answeredFrom !== 'local' && } + {index === turns.length - 1 && turn.status !== 'generating' && (!turn.answeredFrom || ASKABLE.has(turn.answeredFrom)) && } ; })} } diff --git a/graph-ui/src/browser-ai/question-intent.probes.test.ts b/graph-ui/src/browser-ai/question-intent.probes.test.ts new file mode 100644 index 000000000..283a621b0 --- /dev/null +++ b/graph-ui/src/browser-ai/question-intent.probes.test.ts @@ -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); + }); +}); diff --git a/graph-ui/src/browser-ai/question-intent.ts b/graph-ui/src/browser-ai/question-intent.ts index f4a05d620..7c2b05593 100644 --- a/graph-ui/src/browser-ai/question-intent.ts +++ b/graph-ui/src/browser-ai/question-intent.ts @@ -107,14 +107,23 @@ export function noQuestionAnswer(typed: string, language: 'en' | 'de', subject: } /** 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(); 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))); + }; } From c395bd57bb860e3c12fdd1ac7c7b4dde7042a618 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 14:38:27 +0200 Subject: [PATCH 3/9] fix(ui): say "Back to" and send a returned topic's own earlier turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coming back to a file or selection that already had turns showed "New topic: . Earlier messages are not sent with these questions." and sent none of them, so "Und was noch?" got "Ja, und noch." The divider now reads "Back to: X. Earlier messages about it are sent again." (German "Zurück zu: X. ..." for a German question), and the request carries the earlier turns of that topic again. Turns of other topics still stay out. A short follow-up right after a change of topic ("und", "mehr", "und was noch?", "and?", "more") has no turn of its topic to follow. It is now answered without the model: earlier messages were about another topic and are not sent, please ask the full question, with the example questions of a prompt that asks nothing. The chat check for K17 now expects the return to send the file's own earlier question and to say "Zurück zu". Signed-off-by: Bernhard Jackiewicz --- .../src/browser-ai/BrowserChatDock.test.tsx | 14 ++-- .../BrowserChatDock.topics.test.tsx | 75 +++++++++++++++++++ graph-ui/src/browser-ai/BrowserChatDock.tsx | 27 ++++--- .../browser-ai/chat-context.return.test.ts | 38 ++++++++++ graph-ui/src/browser-ai/chat-context.test.ts | 8 +- graph-ui/src/browser-ai/chat-context.ts | 44 +++++++---- graph-ui/src/browser-ai/chat-model.ts | 7 +- graph-ui/src/browser-ai/question-intent.ts | 25 +++++-- graph-ui/src/browser-ai/strings.ts | 6 ++ graph-ui/tools/handtest-fixes-chat.mjs | 16 ++-- 10 files changed, 209 insertions(+), 51 deletions(-) create mode 100644 graph-ui/src/browser-ai/BrowserChatDock.topics.test.tsx create mode 100644 graph-ui/src/browser-ai/chat-context.return.test.ts diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index 5d349e140..506686218 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -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 () => { diff --git a/graph-ui/src/browser-ai/BrowserChatDock.topics.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.topics.test.tsx new file mode 100644 index 000000000..894b26595 --- /dev/null +++ b/graph-ui/src/browser-ai/BrowserChatDock.topics.test.tsx @@ -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?'); + }); +}); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index 4510a07dc..4df71d053 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -3,7 +3,7 @@ import type { BrowserAiProgress } from './browser-ai-controller'; import { createBrowserChatRuntime, type BrowserChatRuntime } from './browser-ai-runtime'; import { BROWSER_MODELS, getBrowserModel, isBrowserModelCached, removeBrowserModelCache, type BrowserModel } from './model-policy'; import { keepAgent, loadProgress, offerAgent, peekAgent, takeAgent, type AgentHandover, type LoadProgress } from './agent-handover'; -import { buildChatMessages, selectionLocation, snapshotAttachment, snapshotReaderContext, trimChatHistory, type BrowserChatAttachment, type BrowserChatContext, type BrowserChatReaderContext, type BrowserChatSource, type BrowserChatTurn } from './chat-model'; +import { buildChatMessages, selectionLocation, sentInHistory, snapshotAttachment, snapshotReaderContext, trimChatHistory, type BrowserChatAttachment, type BrowserChatContext, type BrowserChatReaderContext, type BrowserChatSource, type BrowserChatTurn } from './chat-model'; import { explanationInput, EXPLANATION_DELAY_MS, type ExplanationInput } from './proactive-selection'; import { prepareExplanationContext, selectionSummary, type PreparedExplanationContext } from './explanation-context'; import { AUTO_INPUT_TOKENS, AUTO_OUTPUT_TOKENS, citedInterpretation, explanationMode, explanationSentence, namesNotIn, parseExplanationResponse, explanationMessages, formatExplanationEvidence } from './explanation-response'; @@ -15,11 +15,11 @@ import { isGpuRuntimeFailure, BrowserRuntimeFatalError } from './runtime-fault'; import ChatMarkdown from './ChatMarkdown'; import AgentSettingsDialog from './AgentSettingsDialog'; import { useChatHistory } from './use-chat-history'; -import { chatTopic, followedTopic, missingContextAnswer, questionLanguage, topicHistory } from './chat-context'; +import { chatTopic, contextFreeFollowUp, followedTopic, missingContextAnswer, questionLanguage, topicDivider, topicHistory } from './chat-context'; import { isDataFile, readerFacts } from './file-facts'; import { fileOutline } from './file-outline'; import { readGalaxyEvidence, type GalaxyEvidence } from './galaxy-evidence'; -import { generalQuestion, knownNames, noQuestion, noQuestionAnswer } from './question-intent'; +import { followUpAnswer, generalQuestion, knownNames, noQuestion, noQuestionAnswer, type ExampleSubject } from './question-intent'; import './browser-chat.css'; export type { BrowserChatAttachment, BrowserChatContext, BrowserChatReaderContext, BrowserChatSource } from './chat-model'; @@ -646,11 +646,16 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " // A prompt that asks nothing ("test", "hallo") gets questions it could ask, not an echo of the model (C6). const known = knownNames({ galaxy, names: galaxy ? galaxyNames(galaxy) : fileSource ? [fileSource.path] : [], texts: [fileSource?.text ?? '', source?.text ?? '', ...currentGraph.filter(() => !galaxy).map(item => item.label), ...extra.map(item => item.label)] }); - if (!retry && noQuestion(prompt, known)) { - const subject = galaxy ? { kind: 'galaxy' as const, name: galaxy.label } : fileSource?.kind === 'selection' || source ? { kind: 'marked' as const } - : { kind: 'other' as const, name: fileSource ? fileSource.path.split('/').pop()! : topic?.label ?? '' }; + const subject: ExampleSubject = galaxy ? { kind: 'galaxy', name: galaxy.label } : fileSource?.kind === 'selection' || source ? { kind: 'marked' } + : { kind: 'other', name: fileSource ? fileSource.path.split('/').pop()! : topic?.label ?? '' }; + // "und was noch?" right after a change of topic would reach the model without context: + // this topic has no earlier turn, and those of other topics are not sent (B4). + const hint = !retry && noQuestion(prompt, known) ? noQuestionAnswer(prompt, language, subject) + : !retry && topic && contextFreeFollowUp(prompt) && !topicHistory(turns, topic).some(sentInHistory) && turns.some(item => item.topic && item.topic.key !== topic.key) + ? followUpAnswer(prompt, language, subject, topic.label) : undefined; + if (hint) { setTurns(previous => [...previous, { id: `local-turn-${crypto.randomUUID()}`, prompt, attachment: source, readerContext: reader, context: extra, topic, replyLanguage: language, - ...currentGraph[0] ? { listedFrom: currentGraph[0] } : {}, modelId: model.id, request: [], answer: noQuestionAnswer(prompt, language, subject), status: 'complete', answeredFrom: 'hint' }]); + ...currentGraph[0] ? { listedFrom: currentGraph[0] } : {}, modelId: model.id, request: [], answer: hint, status: 'complete', answeredFrom: 'hint' }]); consume(); return; } @@ -667,8 +672,8 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " // listed facts and one checked model sentence, in the language of the question (C5). const grounded = !retry && !source && !extra.length && galaxy && generalQuestion(prompt, galaxyNames(galaxy)) === 'general' ? { graph: currentGraph[0], summary: selectionSummary(currentGraph[0], language), subject: selectionSubject(currentGraph[0]) } : undefined; - // Answers about another file or selection stay out, and so do those from before the - // last change of topic: an earlier wrong answer must not become evidence for this one (K17). + // Answers about another file or selection stay out (K17); those of this topic come along, + // also from before the reader went elsewhere and came back (B4). const earlier = topicHistory(ask ? turns.filter(item => item.id !== retry.id) : turns, topic); let history: ChatTurn[] = earlier; const makeRequest = () => buildChatMessages(history, prompt, source, extra, reader, currentGraph, packet ? formatExplanationEvidence(packet) : undefined, questionLanguage(prompt)); @@ -882,9 +887,9 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " {turns.map((turn, index) => { const replyWords = relationshipWords[turn.replyLanguage === 'de' ? 'de' : 'en']; // The model's own answers carry their note in the language of the question (B1). const ownWords = chatRound3Text[turn.replyLanguage ?? questionLanguage(turn.prompt)]; + const divider = topicDivider(turns, index); return
- {turn.topic && index > 0 && turn.topic.key !== turns.slice(0, index).reverse().find(item => item.topic)?.topic?.key - &&

{browserChatText.topicBreak(turn.topic.label)}

} + {turn.topic && divider &&

{divider === 'back' ? ownWords.backTo(turn.topic.label) : browserChatText.topicBreak(turn.topic.label)}

}
You {turn.askedModel && {ownWords.askedModel}}
diff --git a/graph-ui/src/browser-ai/chat-context.return.test.ts b/graph-ui/src/browser-ai/chat-context.return.test.ts new file mode 100644 index 000000000..efc72a807 --- /dev/null +++ b/graph-ui/src/browser-ai/chat-context.return.test.ts @@ -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 => + ({ 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); + }); +}); diff --git a/graph-ui/src/browser-ai/chat-context.test.ts b/graph-ui/src/browser-ai/chat-context.test.ts index 9dde9c501..095077539 100644 --- a/graph-ui/src/browser-ai/chat-context.test.ts +++ b/graph-ui/src/browser-ai/chat-context.test.ts @@ -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]); }); diff --git a/graph-ui/src/browser-ai/chat-context.ts b/graph-ui/src/browser-ai/chat-context.ts index 2e799649a..332a54ef7 100644 --- a/graph-ui/src/browser-ai/chat-context.ts +++ b/graph-ui/src/browser-ai/chat-context.ts @@ -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(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'; } diff --git a/graph-ui/src/browser-ai/chat-model.ts b/graph-ui/src/browser-ai/chat-model.ts index ed5592e24..c999c969c 100644 --- a/graph-ui/src/browser-ai/chat-model.ts +++ b/graph-ui/src/browser-ai/chat-model.ts @@ -146,6 +146,11 @@ export function trimChatHistory(turns: readonly T[], * "was macht diese klasse?" (C4). */ export const ANSWER_LANGUAGE = { en: 'Answer in English.', de: 'Answer in German.' } as const; +/** Whether a turn goes into the history of a later request: an answer, not a suggestion, + * the chat's own hint or its reply that a question has no context. */ +export const sentInHistory = (turn: BrowserChatTurn): boolean => turn.status !== 'error' && turn.status !== 'generating' + && turn.answeredFrom !== 'suggestion' && turn.answeredFrom !== 'local' && turn.answeredFrom !== 'hint'; + export function buildChatMessages(turns: readonly BrowserChatTurn[], prompt: string, attachment?: BrowserChatAttachment, context: readonly BrowserChatContext[] = [], readerContext?: BrowserChatReaderContext, currentContext: readonly BrowserChatContext[] = [], currentEvidence?: string, language?: keyof typeof ANSWER_LANGUAGE): BrowserChatMessage[] { const reader = snapshotReaderContext(readerContext); @@ -156,7 +161,7 @@ export function buildChatMessages(turns: readonly BrowserChatTurn[], prompt: str + JSON.stringify(currentContext.map(({ label, text }) => ({ label, text }))) + '\n--- END CURRENT GRAPH DATA ---' : '') }]; for (const turn of turns) { // A suggestion is a question back to the reader, not an answer the model should build on. - if (turn.status === 'error' || turn.status === 'generating' || turn.answeredFrom === 'suggestion' || turn.answeredFrom === 'local' || turn.answeredFrom === 'hint') continue; + if (!sentInHistory(turn)) continue; messages.push({ role: 'user', content: userMessage(turn.prompt, reader || turn.readerContext ? undefined : turn.attachment, turn.context) }); if (turn.answer) messages.push({ role: 'assistant', content: turn.answer }); } diff --git a/graph-ui/src/browser-ai/question-intent.ts b/graph-ui/src/browser-ai/question-intent.ts index 7c2b05593..8ce098f3f 100644 --- a/graph-ui/src/browser-ai/question-intent.ts +++ b/graph-ui/src/browser-ai/question-intent.ts @@ -1,6 +1,6 @@ import type { GalaxyEvidence } from './galaxy-evidence'; import { typoBudget, withinEdits } from './relationship-answer'; -import { noQuestionText } from './strings'; +import { chatRound3Text, noQuestionText } from './strings'; /** What a prompt asks of the current selection or open file, before any model sees it. * @@ -96,14 +96,27 @@ export function noQuestion(prompt: string, known: (word: string) => boolean): bo /** What the examples of a prompt without a question are about. */ export type ExampleSubject = { kind: 'galaxy' | 'other'; name: string } | { kind: 'marked' }; +/** Two or three questions that work for the selection, as a Markdown list. */ +function examples(language: 'en' | 'de', subject: ExampleSubject): string { + const text = noQuestionText[language]; + const items = subject.kind === 'marked' ? [text.markedDoes, text.markedInDetail] + : subject.kind === 'galaxy' ? [text.whatDoes(subject.name), text.whoCalls(subject.name), text.whatCalls(subject.name)] + : [text.whatDoes(subject.name), text.inDetail(subject.name)]; + return items.map(example => `- ${example}`).join('\n'); +} +const shownPrompt = (typed: string) => typed.replace(/\s+/g, ' ').replace(/["`]/g, "'").trim().slice(0, 40); + /** "No question was recognized in "test"." and two or three questions that work for the selection (C6). */ export function noQuestionAnswer(typed: string, language: 'en' | 'de', subject: ExampleSubject): string { const text = noQuestionText[language]; - const examples = subject.kind === 'marked' ? [text.markedDoes, text.markedInDetail] - : subject.kind === 'galaxy' ? [text.whatDoes(subject.name), text.whoCalls(subject.name), text.whatCalls(subject.name)] - : [text.whatDoes(subject.name), text.inDetail(subject.name)]; - const shown = typed.replace(/\s+/g, ' ').replace(/["`]/g, "'").trim().slice(0, 40); - return `${text.heading(shown)}\n\n${examples.map(example => `- ${example}`).join('\n')}\n\n_${text.note}_`; + return `${text.heading(shownPrompt(typed))}\n\n${examples(language, subject)}\n\n_${text.note}_`; +} + +/** A follow-up ("und was noch?") right after a change of topic: the earlier turns are about + * another topic and are not sent, so the model would answer it without context ("Ja, und + * noch."). It asks for the full question with the same examples (B4). */ +export function followUpAnswer(typed: string, language: 'en' | 'de', subject: ExampleSubject, topic: string): string { + return `${chatRound3Text[language].followUp(shownPrompt(typed), topic)}\n\n${examples(language, subject)}\n\n_${noQuestionText[language].note}_`; } /** The names a prompt may use for what is at hand: the selection, the symbols around it, the diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index b389df598..5d5a0dfe9 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -391,6 +391,10 @@ const englishRound3Text = { /** Under every answer the model wrote freely (B1). */ modelNote: 'Generated by the local model; it can be wrong.', askAgain: 'Ask again', + /** Above the first question about a file or selection that has earlier turns (B4). */ + backTo: (label: string) => `Back to: ${label}. Earlier messages about it are sent again.`, + /** A follow-up ("und was noch?") right after a change of topic (B4). */ + followUp: (typed: string, label: string) => `"${typed}" refers to earlier messages. Those were about another topic and are not sent with questions about ${label}. Please ask the full question, for example:`, }; export const chatRound3Text: { en: typeof englishRound3Text; de: typeof englishRound3Text } = { en: englishRound3Text, @@ -398,5 +402,7 @@ export const chatRound3Text: { en: typeof englishRound3Text; de: typeof englishR askedModel: 'An das lokale Modell gestellt', modelNote: 'Vom lokalen Modell erzeugt; kann falsch sein.', askAgain: 'Erneut fragen', + backTo: (label: string) => `Zurück zu: ${label}. Die früheren Nachrichten dazu werden wieder mitgeschickt.`, + followUp: (typed: string, label: string) => `"${typed}" bezieht sich auf frühere Nachrichten. Die betrafen ein anderes Thema und werden mit Fragen zu ${label} nicht mitgeschickt. Stell die Frage bitte vollständig, zum Beispiel:`, }, }; diff --git a/graph-ui/tools/handtest-fixes-chat.mjs b/graph-ui/tools/handtest-fixes-chat.mjs index 5a9cb46ac..28a4a6b20 100644 --- a/graph-ui/tools/handtest-fixes-chat.mjs +++ b/graph-ui/tools/handtest-fixes-chat.mjs @@ -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); } From 30d4d6ad005ce10fa2d64cc5eaeb11fb01725746 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 14:42:49 +0200 Subject: [PATCH 4/9] fix(ui): outline INI files and say what well-known files are for "was macht diese datei?" on tox.ini, setup.cfg, .flake8, .editorconfig, pytest.ini or .coveragerc went to the model, which wrote "Testverwendungen" for tox.ini. These files now get an outline read from them like YAML, JSON and TOML: each section with its keys and short values, a value continued on indented lines as its list, bounded with "+N more". The automatic card counts sections with the same reader, and .flake8, .coveragerc and .pylintrc now count as INI files there too. The outline also says what a well-known file is for, in one line after its heading, when the name and the structure fit: pre-commit, GitHub workflows, package.json, pyproject.toml, tox.ini, setup.cfg, Docker Compose, Read the Docs, EditorConfig, tsconfig, flake8, pytest and coverage.py. Other files get no such line. The heading no longer says "Read from the file:"; the note at the end still does. Signed-off-by: Bernhard Jackiewicz --- .../BrowserChatDock.config-files.test.tsx | 23 ++++ .../src/browser-ai/BrowserChatDock.test.tsx | 4 +- graph-ui/src/browser-ai/file-facts.ts | 33 +++-- graph-ui/src/browser-ai/file-kind.ts | 2 + .../src/browser-ai/file-outline.ini.test.ts | 121 ++++++++++++++++++ graph-ui/src/browser-ai/file-outline.test.ts | 4 +- graph-ui/src/browser-ai/file-outline.ts | 53 ++++++-- graph-ui/src/browser-ai/strings.ts | 36 ++++++ 8 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 graph-ui/src/browser-ai/BrowserChatDock.config-files.test.tsx create mode 100644 graph-ui/src/browser-ai/file-outline.ini.test.ts diff --git a/graph-ui/src/browser-ai/BrowserChatDock.config-files.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.config-files.test.tsx new file mode 100644 index 000000000..4241df034 --- /dev/null +++ b/graph-ui/src/browser-ai/BrowserChatDock.config-files.test.tsx @@ -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']); + }); +}); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index 506686218..196973aff 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -1611,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.'); @@ -1619,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(); diff --git a/graph-ui/src/browser-ai/file-facts.ts b/graph-ui/src/browser-ai/file-facts.ts index 0eec1a177..d2876df6d 100644 --- a/graph-ui/src/browser-ai/file-facts.ts +++ b/graph-ui/src/browser-ai/file-facts.ts @@ -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); diff --git a/graph-ui/src/browser-ai/file-kind.ts b/graph-ui/src/browser-ai/file-kind.ts index c02924312..4b6affd55 100644 --- a/graph-ui/src/browser-ai/file-kind.ts +++ b/graph-ui/src/browser-ai/file-kind.ts @@ -12,6 +12,8 @@ const KINDS: Readonly> = { 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). */ diff --git a/graph-ui/src/browser-ai/file-outline.ini.test.ts b/graph-ui/src/browser-ai/file-outline.ini.test.ts new file mode 100644 index 000000000..040aaac45 --- /dev/null +++ b/graph-ui/src/browser-ai/file-outline.ini.test.ts @@ -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'); + }); +}); diff --git a/graph-ui/src/browser-ai/file-outline.test.ts b/graph-ui/src/browser-ai/file-outline.test.ts index 8bd592f24..781c63676 100644 --- a/graph-ui/src/browser-ai/file-outline.test.ts +++ b/graph-ui/src/browser-ai/file-outline.test.ts @@ -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._'); diff --git a/graph-ui/src/browser-ai/file-outline.ts b/graph-ui/src/browser-ai/file-outline.ts index ec8e9d0a9..5ed60d04d 100644 --- a/graph-ui/src/browser-ai/file-outline.ts +++ b/graph-ui/src/browser-ai/file-outline.ts @@ -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 @@ -61,8 +62,22 @@ function tomlTree(text: string): YamlValue | undefined { 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 +85,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 => 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 +143,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 +167,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'); } diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index 5d5a0dfe9..145f83c82 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -395,6 +395,25 @@ const englishRound3Text = { 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:`, + /** 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, @@ -404,5 +423,22 @@ export const chatRound3Text: { en: typeof englishRound3Text; de: typeof englishR 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:`, + 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.', + }, }, }; From da5751aefc8e26d40131f0e352b7ef93c0a27cd3 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 14:47:18 +0200 Subject: [PATCH 5/9] fix(ui): say what the selected code declares in grounded answers A short general question about JSONBAgg ("was macht diese klasse? sehr kurze antwort") got four lines of counts: the model's sentence claimed "Arrays" and was dropped every time, so the answer never said what the class is. The answer and the automatic card now list the lines read from the selected symbol's source under "In the source:" ("Im Quelltext:"), between the graph facts and the checked sentence. For a Python class that is its definition with its bases, its class level assignments and then its methods with their decorators. For a function it is the signature, joined where it runs over several lines, the first line of its docstring and, when the body has at most four lines, the body. At most six lines; the rest is counted ("+3 more attributes and methods"). Other languages get their definition line. Without source nothing changes. Signed-off-by: Bernhard Jackiewicz --- .../BrowserChatDock.code-facts.test.tsx | 62 ++++++++ graph-ui/src/browser-ai/BrowserChatDock.tsx | 32 ++-- graph-ui/src/browser-ai/code-facts.test.ts | 54 +++++++ graph-ui/src/browser-ai/code-facts.ts | 141 ++++++++++++++++++ graph-ui/src/browser-ai/strings.ts | 7 + 5 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 graph-ui/src/browser-ai/BrowserChatDock.code-facts.test.tsx create mode 100644 graph-ui/src/browser-ai/code-facts.test.ts create mode 100644 graph-ui/src/browser-ai/code-facts.ts diff --git a/graph-ui/src/browser-ai/BrowserChatDock.code-facts.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.code-facts.test.tsx new file mode 100644 index 000000000..6640ee775 --- /dev/null +++ b/graph-ui/src/browser-ai/BrowserChatDock.code-facts.test.tsx @@ -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'); + }); +}); diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index 4df71d053..f33efc161 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -8,6 +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 { relationshipAnswer, relationshipSuggestion } from './relationship-answer'; import { clampTokenLimits, tokenLimitBounds, tokenLimitsFor, useAgentPreferences, type TokenLimits } from './agent-preferences'; import { browserChatText, chatRound3Text, groundedText, relationshipWords } from './strings'; @@ -73,15 +74,20 @@ const sizeLabel = (bytes: number): string => bytes >= 1_000_000_000 ? `${(bytes /** 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 { +/** 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 = 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'); + 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
@@ -417,6 +423,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; @@ -430,7 +439,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; @@ -442,7 +451,7 @@ 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); @@ -687,6 +696,9 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " } if (grounded) packet = prepareExplanationContext(undefined, packetGraph, 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; // 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; @@ -741,7 +753,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = " 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 } : {}, ...ask || retry?.askedModel ? { askedModel: true } : {} }; @@ -760,7 +772,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, 'graph', language, code); setTurns(previous => previous.map(item => item.id === id ? { ...item, answer, status: stopRequested.current ? 'stopped' : 'complete' } : item)); return; } @@ -772,7 +784,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, 'graph', 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 new file mode 100644 index 000000000..49a10acea --- /dev/null +++ b/graph-ui/src/browser-ai/code-facts.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { codeFacts, codeFactsMarkdown } 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('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); + }); +}); diff --git a/graph-ui/src/browser-ai/code-facts.ts b/graph-ui/src/browser-ai/code-facts.ts new file mode 100644 index 000000000..fbba2dcac --- /dev/null +++ b/graph-ui/src/browser-ai/code-facts.ts @@ -0,0 +1,141 @@ +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> = { 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*(?:\/\/|\/\*|\*|#|--|