- {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*(?:\/\/|\/\*|\*|#|--|