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