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 <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-04 14:47:18 +02:00
1 parent 30d4d6ad00
commit da5751aefc
5 files changed
+286 -10

No files matched your search

@@ -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');
});
});
+22 -10
View File
@@ -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 <details className="cbm-chat-attachment">
@@ -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;
@@ -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);
});
});
+141
View File
@@ -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<Record<string, string>> = { py: 'python', pyi: 'python', ts: 'typescript', tsx: 'typescript', js: 'javascript', mjs: 'javascript', jsx: 'javascript',
go: 'go', rs: 'rust', java: 'java', c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', rb: 'ruby', sh: 'bash', kt: 'kotlin', cs: 'csharp', php: 'php', swift: 'swift' };
const extension = (path: string) => (path.split('/').pop() ?? path).replace(/-tpl$/, '').split('.').slice(1).pop()?.toLowerCase() ?? '';
const indentOf = (line: string) => line.length - line.trimStart().length;
const bounded = (line: string) => line.length > LINE ? `${line.slice(0, LINE - 1)}…` : line;
const escape = (name: string) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Brackets still open after `text`, outside strings. */
function openBrackets(text: string): number {
let depth = 0, quote: string | undefined;
for (let index = 0; index < text.length; index++) {
const character = text[index];
if (quote) { if (character === '\\') index++; else if (character === quote) quote = undefined; continue; }
if (character === '#') break;
if (character === '"' || character === "'") quote = character;
else if ('([{'.includes(character)) depth++;
else if (')]}'.includes(character)) depth--;
}
return depth;
}
/** The statement starting at `start`, joined over the lines its brackets keep open. */
function statement(lines: readonly string[], start: number): { text: string; end: number } {
let text = lines[start].trim(), end = start;
while (openBrackets(text) > 0 && end + 1 < lines.length && end - start < 8) text += ` ${lines[++end].trim()}`;
return { text: text.replace(/([([{])\s+/g, '$1').replace(/\s+([)\]}])/g, '$1').replace(/,([)\]}])/g, '$1'), end };
}
/** The first line of a docstring right after line `after`, as it is written (one or two lines). */
function docstring(lines: readonly string[], after: number, indent: string): string[] {
const at = lines.findIndex((line, index) => index > after && line.trim());
const first = at >= 0 ? lines[at].trim() : '';
if (!/^[rubRUB]{0,2}("""|''')/.test(first)) return [];
const bare = /^[rubRUB]{0,2}("""|''')$/.test(first);
const next = bare ? lines.slice(at + 1).find(line => line.trim())?.trim() : undefined;
return bare && next ? [`${indent}${first}`, `${indent}${bounded(next)}`] : [`${indent}${bounded(first)}`];
}
/** Where a docstring after line `after` ends, or `after` without one. */
function docstringEnd(lines: readonly string[], after: number): number {
const at = lines.findIndex((line, index) => index > after && line.trim());
const match = at >= 0 ? /^[rubRUB]{0,2}("""|''')/.exec(lines[at].trim()) : null;
if (!match) return after;
const rest = lines[at].trim().slice(match[0].length);
if (rest.includes(match[1])) return at;
const close = lines.findIndex((line, index) => index > at && line.includes(match[1]));
return close >= 0 ? close : lines.length - 1;
}
function pythonFacts(lines: readonly string[], name?: string): CodeFacts | undefined {
const definition = new RegExp(`^\\s*(?:async\\s+)?(?:def|class)\\s+${name ? `${escape(name)}\\b` : '\\w'}`);
let start = lines.findIndex(line => definition.test(line));
if (start < 0) start = lines.findIndex(line => /^\s*(?:async\s+)?(?:def|class)\s+\w/.test(line));
if (start < 0) return undefined;
const base = indentOf(lines[start]);
const shift = (line: string) => line.slice(Math.min(base, indentOf(line)));
const header = statement(lines, start);
const isClass = /^class\s/.test(header.text);
const inner = lines.slice(header.end + 1).find(line => line.trim() && !line.trim().startsWith('#'));
const pad = ' '.repeat(Math.max(4, inner ? indentOf(inner) - base : 4));
const out = [bounded(header.text), ...docstring(lines, header.end, pad)];
const bodyStart = docstringEnd(lines, header.end) + 1;
const body = lines.slice(bodyStart).filter(line => line.trim() && !line.trim().startsWith('#') && indentOf(line) > base);
if (!isClass) {
const shown = body.length <= SHORT_BODY ? body.map(line => bounded(shift(line).trimEnd())) : [];
const room = Math.max(0, CODE_FACT_LINES - out.length);
return { lines: [...out, ...shown.slice(0, room)], more: body.length - Math.min(shown.length, room), unit: 'lines', fence: 'python' };
}
// Members at the class's own body indent: assignments first, then methods with their decorators.
const memberIndent = inner ? indentOf(inner) : base + 4;
const assignments: string[][] = [], methods: string[][] = [];
let decorators: string[] = [];
for (let index = bodyStart; index < lines.length; index++) {
const line = lines[index];
if (!line.trim() || line.trim().startsWith('#')) continue;
if (indentOf(line) <= base) break;
if (indentOf(line) !== memberIndent) continue;
const { text, end } = statement(lines, index);
if (text.startsWith('@')) { decorators.push(`${pad}${bounded(text)}`); index = end; continue; }
if (/^(?:async\s+)?(?:def|class)\s/.test(text)) methods.push([...decorators, `${pad}${bounded(text)}`]);
else if (/^[A-Za-z_]\w*(?:\s*:[^=]+)?\s*=[^=]/.test(text)) assignments.push([`${pad}${bounded(text)}`]);
decorators = [];
index = end;
}
let more = 0;
for (const member of [...assignments, ...methods]) {
if (out.length + member.length <= CODE_FACT_LINES) out.push(...member); else more++;
}
return { lines: out, more, unit: 'members', fence: 'python' };
}
/** The line that declares `name`, or the first line of code; without its opening brace. */
function definitionLine(lines: readonly string[], name: string | undefined, fence: string): CodeFacts | undefined {
const code = lines.filter(line => line.trim() && !/^\s*(?:\/\/|\/\*|\*|#|--|<!--)/.test(line));
const named = name ? code.find(line => new RegExp(`(?:^|[^\\w$])${escape(name)}(?:[^\\w$]|$)`).test(line)) : undefined;
const line = (named ?? code[0])?.trim().replace(/\s*\{\s*$/, '');
return line ? { lines: [bounded(line)], more: 0, unit: 'lines', fence } : undefined;
}
/** The facts of the code a selection stands for, or undefined without code. */
export function codeFacts(source: { text: string; path: string }, subject?: { name: string; kind?: string }): CodeFacts | undefined {
const lines = source.text.replace(/\r\n?/g, '\n').split('\n');
if (!source.text.trim()) return undefined;
const fence = FENCES[extension(source.path)] ?? '';
return (fence === 'python' ? pythonFacts(lines, subject?.name) : undefined) ?? definitionLine(lines, subject?.name, fence);
}
/** "In the source:" with the lines as a code block and what did not fit, in the language of the question. */
export function codeFactsMarkdown(facts: CodeFacts, language: 'en' | 'de'): string {
const words = chatRound3Text[language];
const mark = facts.lines.some(line => line.includes('```')) ? '~~~' : '```';
const more = facts.more ? `\n\n${facts.unit === 'members' ? words.moreMembers(facts.more) : words.moreLines(facts.more)}` : '';
return `${words.inSource}\n\n${mark}${facts.fence}\n${facts.lines.join('\n')}\n${mark}${more}`;
}
+7
View File
@@ -395,6 +395,10 @@ 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:`,
/** Above the lines read from the selected code (B2). */
inSource: 'In the source:',
moreMembers: (total: number) => `+${total.toLocaleString('en-US')} more ${total === 1 ? 'attribute or method' : 'attributes and methods'}`,
moreLines: (total: number) => `+${counted(total, 'more line', 'more lines')}`,
/** 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',
@@ -423,6 +427,9 @@ 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:`,
inSource: 'Im Quelltext:',
moreMembers: (total: number) => `+${total.toLocaleString('de-DE')} ${total === 1 ? 'weiteres Attribut oder weitere Methode' : 'weitere Attribute und Methoden'}`,
moreLines: (total: number) => `+${gezaehlt(total, 'weitere Zeile', 'weitere Zeilen')}`,
outlineHeading: (name: string, kind: string, lines: number) => `${name}: ${kind}, ${gezaehlt(lines, 'Zeile', 'Zeilen')}.`,
iniKind: 'INI-Konfiguration',
purposes: {