diff --git a/graph-ui/src/browser-ai/explanation-context.test.ts b/graph-ui/src/browser-ai/explanation-context.test.ts index 760ee71ba..a5308280a 100644 --- a/graph-ui/src/browser-ai/explanation-context.test.ts +++ b/graph-ui/src/browser-ai/explanation-context.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { BrowserChatContext, BrowserChatReaderContext } from './chat-model'; import { prepareExplanationContext } from './explanation-context'; +import { JSONB_AGG_CALLERS, jsonbAggEvidence, jsonbAggScope } from './galaxy-evidence.fixture'; function reader(text: string, kind: 'file' | 'selection' = 'file'): BrowserChatReaderContext { const lines = text.split('\n'); @@ -138,6 +139,63 @@ describe('bounded explanation evidence', () => { } }); + it('describes a Galaxy selection as readable facts with every caller, grouped by direction and edge type', () => { + const prepared = prepareExplanationContext(undefined, jsonbAggEvidence(), 3200); + const text = outputText(prepared); + expect(text).toContain('Selected: JSONBAgg (Class) in django/contrib/postgres/aggregates/general.py:50-54.'); + expect(text).toContain('Scope: 1 hop in both directions, all relationship types; 15 symbols and 25 relationships; complete for the indexed graph.'); + expect(text).toContain('Incoming relationships: 23 from 12 symbols.'); + const line = (type: string) => text.split('\n').find(item => item.startsWith(`- ${type} `)) ?? ''; + for (const type of ['CALLS', 'TESTS']) { + expect(line(type)).toMatch(new RegExp(`^- ${type} from 11: `)); + for (const name of JSONB_AGG_CALLERS) expect(line(type)).toContain(name); + expect(line(type)).toContain('(Method, tests/postgres_tests/test_aggregates.py)'); + } + expect(line('DEFINES')).toBe('- DEFINES from 1: general.py (File, django/contrib/postgres/aggregates/general.py)'); + expect(text).toContain('Outgoing relationships: 2 to 2 symbols.'); + expect(line('INHERITS')).toMatch(/^- INHERITS to 2: OrderableAggMixin .*Aggregate/); + // Callers never appear under the outgoing side. + expect(text.slice(text.indexOf('Outgoing relationships'))).not.toContain('test_jsonb_agg'); + expect(prepared.capacity).toBeUndefined(); + }); + + it('keeps internal snapshot keys, provenance fields and omission paths out of the Galaxy prompt', () => { + const text = outputText(prepareExplanationContext(undefined, jsonbAggEvidence())); + expect(text).not.toMatch(/Snapshot|renderedNodes|renderedEdges|generation|rootCount|omittedRoots|qualifiedName|query_graph|\broots\b|\bview\b|\$\.|omissions|typeCounts|\.items\[/); + expect(text).not.toContain('django-demo.'); + }); + + it('lists names until the budget ends, then says how many more there are and reports the capacity', () => { + const prepared = prepareExplanationContext(undefined, jsonbAggEvidence(), 1900); + const text = outputText(prepared); + expect(text).toContain('Incoming relationships: 23 from 12 symbols.'); + const calls = text.split('\n').find(item => item.startsWith('- CALLS from 11: '))!; + const listed = JSONB_AGG_CALLERS.filter(name => calls.includes(`${name},`) || calls.includes(`${name} (`)).length; + expect(listed).toBeGreaterThan(0); + expect(calls).toMatch(new RegExp(`; \\+${11 - listed} more$`)); + expect(prepared.capacity).toMatchObject({ nodes: 15, edges: 25 }); + expect(prepared.capacity!.shown).toBeLessThan(25); + expect(prepared.characterCount).toBeLessThanOrEqual(1900); + }); + + it('classifies before bounding: a large caller set keeps its complete count beyond the named sample', () => { + const scope = jsonbAggScope(); + const many = Array.from({ length: 60 }, (_, index) => ({ source: 1000 + index, target: 32360, type: 'CALLS' })); + const context = jsonbAggEvidence({ edges: [...scope.edges, ...many] }); + const text = outputText(prepareExplanationContext(undefined, context, 6000)); + // The extra callers are not in the node list, so they are named by id; the count stays exact. + expect(text).toContain('- CALLS from 71: '); + expect(text).toMatch(/- CALLS from 71: .*; \+47 more/); + }); + + it('says when a side was not loaded instead of reporting no callers', () => { + const text = outputText(prepareExplanationContext(undefined, jsonbAggEvidence({ direction: 'outbound' }))); + expect(text).toContain('Incoming relationships: not loaded; the scope does not follow incoming edges.'); + expect(text).toContain('outgoing only'); + expect(outputText(prepareExplanationContext(undefined, jsonbAggEvidence({ state: 'loading-partial-preview' })))) + .toContain('still loading, so this is a partial preview'); + }); + it('bounds hostile giant graph data, source labels and very small requested budgets', () => { const context = graph({ name: 'N'.repeat(100_000), members: Array(1000).fill({ detail: 'D'.repeat(1000) }) }); for (const budget of [0, 100, 800, 4000]) { diff --git a/graph-ui/src/browser-ai/explanation-context.ts b/graph-ui/src/browser-ai/explanation-context.ts index 902339a52..813f28be1 100644 --- a/graph-ui/src/browser-ai/explanation-context.ts +++ b/graph-ui/src/browser-ai/explanation-context.ts @@ -1,4 +1,6 @@ import { snapshotReaderContext, type BrowserChatContext, type BrowserChatReaderContext, type BrowserChatSource } from './chat-model'; +import { fairShares, readGalaxyEvidence, relationshipLine, scopeSentence, selectionSentence, sideLoaded, type GalaxyEvidence } from './galaxy-evidence'; +import { relationshipWords } from './strings'; export interface ExplanationEvidence { id: string; @@ -14,6 +16,8 @@ export interface PreparedExplanationContext { fallback: string; /** Human-readable strings only; callers must still count the final model tokens. */ characterCount: number; + /** Set when the selection's relationships did not all fit: scope size and how many are named. */ + capacity?: { nodes: number; edges: number; shown: number }; } const record = (value: unknown): Record | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) @@ -146,7 +150,14 @@ function graphFacts(value: unknown, name: string, maxCharacters: number): string return lines.join('\n'); } -interface GraphPreparation { facts: string[]; limitations: string[] } +/** A fact renders itself into a character budget. Relationship facts count the + * names they could list, so a bounded prompt can say how much it left out. */ +interface GraphFact { natural: number; render(budget: number): { text: string; listed?: number; total?: number } } +interface GraphPreparation { facts: GraphFact[]; limitations: string[]; scope?: { nodes: number; edges: number } } + +function fixedFact(fact: string): GraphFact { + return { natural: fact.length, render: budget => ({ text: fitGraphFact(fact, budget) }) }; +} function fitGraphFact(fact: string, budget: number): string { if (fact.length <= budget) return fact; @@ -162,10 +173,53 @@ function fitGraphFact(fact: string, budget: number): string { return ''; } +/** Relationships of one side: complete totals first, then each edge type with as + * many names as its fair share of the budget holds, then "+N more". */ +function relationshipFact(galaxy: GalaxyEvidence, side: 'incoming' | 'outgoing'): GraphFact { + const words = relationshipWords.en, groups = galaxy.relationships[side]; + const total = groups.reduce((sum, group) => sum + group.count, 0); + const empty = !sideLoaded(galaxy, side) ? (side === 'incoming' ? words.incomingNotLoaded : words.outgoingNotLoaded) + : !groups.length ? (side === 'incoming' ? words.noIncoming : words.noOutgoing) : undefined; + const header = side === 'incoming' ? words.incoming(total, galaxy.relationships.incomingSymbols) + : words.outgoing(total, galaxy.relationships.outgoingSymbols); + const render = (budget: number) => { + if (empty) return { text: empty.length <= budget ? empty : '' }; + if (header.length > budget) return { text: '', listed: 0, total }; + const natural = groups.map(group => relationshipLine(group, side, Infinity, words).text.length + 1); + const shares = fairShares(natural, budget - header.length); + const lines = groups.map((group, index) => relationshipLine(group, side, shares[index] - 1, words)); + const omittedTypes = lines.filter(item => !item.text).length; + const note = omittedTypes ? words.moreTypes(omittedTypes) : ''; + const text = [header, ...lines.filter(item => item.text).map(item => item.text)].join('\n'); + return { text: note && text.length + note.length + 1 <= budget ? `${text}\n${note}` : text, + listed: lines.reduce((sum, item) => sum + item.listed, 0), total }; + }; + return { natural: render(Infinity).text.length, render }; +} + +/** Galaxy selections in words: what is selected, who relates to it in which + * direction, and how the scope was drawn. No snapshot field names or paths. */ +function galaxyFacts(galaxy: GalaxyEvidence): GraphPreparation { + const words = relationshipWords.en; + const counts = (items: { type: string; count: number }[]) => items.map(item => `${item.type} ${item.count}`).join(', '); + const further = [ + ...galaxy.relationships.internal.length ? [words.internal(counts(galaxy.relationships.internal))] : [], + ...galaxy.relationships.beyond.length ? [words.beyond(counts(galaxy.relationships.beyond))] : [], + ].join('\n'); + return { + facts: [fixedFact(selectionSentence(galaxy).join('\n')), fixedFact(scopeSentence(galaxy, words)), + relationshipFact(galaxy, 'incoming'), relationshipFact(galaxy, 'outgoing'), ...further ? [fixedFact(further)] : []], + limitations: ['Static graph relationships do not establish runtime execution or repository purpose.'], + scope: { nodes: galaxy.nodes, edges: galaxy.edges }, + }; +} + function graphEvidence(context: BrowserChatContext): GraphPreparation { // Producer snapshots are already bounded. Refuse arbitrary giant input before // parsing it; a malformed/foreign envelope is never treated as graph evidence. if (context.text.length > 128_000) return { facts: [], limitations: ['Graph snapshot exceeds the supported evidence size; graph facts unavailable.'] }; + const galaxy = readGalaxyEvidence(context.text); + if (galaxy) return galaxyFacts(galaxy); let parsed: Record | undefined; try { parsed = record(JSON.parse(context.text)); } catch { /* Report unsupported data below. */ } const evidence = record(parsed?.evidence); @@ -184,7 +238,7 @@ function graphEvidence(context: BrowserChatContext): GraphPreparation { graphFacts(evidence.selected, 'Selected', 1400), graphFacts(evidence.relationships, 'Relationships', 1400), graphFacts(evidence.scope, 'Scope', 500), - ].filter(Boolean); + ].filter(Boolean).map(fixedFact); return { facts, limitations: limits }; } @@ -213,19 +267,21 @@ export function prepareExplanationContext(reader?: BrowserChatReaderContext, if (code.omitted) desiredLimits.unshift(`${code.omitted} source characters omitted; ${source?.kind === 'file' ? 'sampled source regions, not the full file' : 'only a prefix of the selected source is included'}. Ranges identify the literal excerpts.`); const evidence = code.evidence; - let remaining = evidenceBudget - evidence.reduce((count, item) => count + item.text.length, 0); - let omittedFacts = 0; + const remaining = evidenceBudget - evidence.reduce((count, item) => count + item.text.length, 0); + let omittedFacts = 0, listed = 0, related = 0; const facts = selectedGraphs.flatMap(prepared => prepared.facts); - for (let index = 0; index < facts.length; index++) { - // Leave room for relationships as well as selected identity. Compact - // complete fact lines; never silently cut a quoted field into a new fact. - const fact = fitGraphFact(facts[index], Math.floor(remaining / (facts.length - index))); - if (fact) { - evidence.push({ id: `graph-${evidence.filter(item => item.source === 'graph').length + 1}`, text: fact, source: 'graph' }); - remaining -= fact.length; - } else omittedFacts++; - } + // Leave room for relationships as well as selected identity: short facts stay + // whole and long ones share the rest. Never silently cut a quoted field. + const shares = fairShares(facts.map(fact => fact.natural), remaining); + facts.forEach((fact, index) => { + const rendered = fact.render(shares[index]); + listed += rendered.listed ?? 0; related += rendered.total ?? 0; + if (rendered.text) evidence.push({ id: `graph-${evidence.filter(item => item.source === 'graph').length + 1}`, text: rendered.text, source: 'graph' }); + else omittedFacts++; + }); if (omittedFacts) desiredLimits.splice(code.omitted ? 1 : 0, 0, `${omittedFacts} graph fact groups omitted from this explanation.`); + const scope = selectedGraphs.find(prepared => prepared.scope)?.scope; + const capacity = scope && listed < related ? { ...scope, shown: listed } : undefined; const limitations: string[] = []; let limitRemaining = limitBudget; for (let index = 0; index < desiredLimits.length; index++) { @@ -241,5 +297,5 @@ export function prepareExplanationContext(reader?: BrowserChatReaderContext, } const characterCount = label.length + fallback.length + evidence.reduce((count, item) => count + item.text.length, 0) + limitations.reduce((count, item) => count + item.length, 0); - return { label, evidence, limitations, fallback, characterCount }; + return { label, evidence, limitations, fallback, characterCount, ...capacity ? { capacity } : {} }; } diff --git a/graph-ui/src/browser-ai/galaxy-evidence.fixture.ts b/graph-ui/src/browser-ai/galaxy-evidence.fixture.ts new file mode 100644 index 000000000..3a0e7e302 --- /dev/null +++ b/graph-ui/src/browser-ai/galaxy-evidence.fixture.ts @@ -0,0 +1,38 @@ +import { graphNodeEvidence, scopeRelationships, selectionEvidenceContext } from '../galaxy/selection-evidence'; +import type { GraphEdge, GraphNode } from '../galaxy/types'; +import type { BrowserChatContext } from './chat-model'; + +/** JSONBAgg in django-demo at one layer, as the call reproduction measured it: + * 11 tests with CALLS and TESTS, DEFINES from general.py, two outgoing INHERITS. */ +export const JSONB_AGG_CALLERS = ['test_default_argument', 'test_empty_result_set', 'test_jsonb_agg', 'test_jsonb_agg_booleanfield_order_by', + 'test_jsonb_agg_charfield_order_by', 'test_jsonb_agg_distinct_false', 'test_jsonb_agg_distinct_true', 'test_jsonb_agg_integerfield_order_by', + 'test_jsonb_agg_jsonfield_order_by', 'test_jsonb_agg_key_index_transforms', 'test_values_list']; + +const node = (id: number, name: string, label: string, file_path: string, start_line?: number, end_line?: number): GraphNode => + ({ id, name, label, file_path, start_line, end_line, qualified_name: `django-demo.${name}`, x: 0, y: 0, z: 0, size: 1, color: '#999' }); + +export function jsonbAggScope(): { nodes: GraphNode[]; edges: GraphEdge[]; roots: Set } { + const root = node(32360, 'JSONBAgg', 'Class', 'django/contrib/postgres/aggregates/general.py', 50, 54); + const tests = JSONB_AGG_CALLERS.map((name, index) => node(100 + index, name, 'Method', 'tests/postgres_tests/test_aggregates.py', 200 + index * 10)); + const module = node(9, 'general.py', 'File', 'django/contrib/postgres/aggregates/general.py'); + const parents = [node(7, 'OrderableAggMixin', 'Class', 'django/contrib/postgres/aggregates/mixins.py'), node(8, 'Aggregate', 'Class', 'django/db/models/aggregates.py')]; + const edges: GraphEdge[] = [ + ...tests.flatMap(test => [{ source: test.id, target: root.id, type: 'CALLS' }, { source: test.id, target: root.id, type: 'TESTS' }]), + { source: module.id, target: root.id, type: 'DEFINES' }, + ...parents.map(parent => ({ source: root.id, target: parent.id, type: 'INHERITS' })), + ]; + return { nodes: [root, ...tests, module, ...parents], edges, roots: new Set([root.id]) }; +} + +/** The context GalaxyPanel publishes for that scope, with optional scope overrides. */ +export function jsonbAggEvidence(overrides: { depth?: number; direction?: string; state?: string; edges?: GraphEdge[] } = {}): BrowserChatContext { + const scope = jsonbAggScope(); + const edges = overrides.edges ?? scope.edges; + const [root] = scope.nodes; + return selectionEvidenceContext({ project: 'django-demo', view: 'galaxy', source: 'query_graph scoped indexed relationships', label: 'JSONBAgg', + selected: { scope: { kind: 'symbol', qualifiedName: root.qualified_name, name: 'JSONBAgg' }, rootCount: 1, roots: [graphNodeEvidence(root)], omittedRoots: 0 }, + scope: { depth: overrides.depth ?? 1, direction: overrides.direction ?? 'both', edgeTypes: 'all', nodes: scope.nodes.length, edges: edges.length }, + relationships: scopeRelationships(scope.nodes, edges, scope.roots), + limitations: { state: overrides.state ?? 'complete-indexed-scope', exhausted: false, indexCoverage: 'unavailable', + interpretation: 'Static indexed relationships, not runtime activity.' } }); +} diff --git a/graph-ui/src/browser-ai/galaxy-evidence.ts b/graph-ui/src/browser-ai/galaxy-evidence.ts new file mode 100644 index 000000000..ed19f7558 --- /dev/null +++ b/graph-ui/src/browser-ai/galaxy-evidence.ts @@ -0,0 +1,145 @@ +import type { RelationshipGroup, ScopeRelationships } from '../galaxy/selection-evidence'; +import type { RelationshipWords } from './strings'; + +/** The Galaxy selection snapshot as readable data. Every field was bounded by its + * producer and is validated again here; names and paths stay untrusted text. */ +export interface GalaxyEvidence { + project: string; + label: string; + /** The selected scope itself (node id, qualified name or path): stable while it loads. */ + identity: unknown; + selectionKind: string; + roots: { name: string; kind?: string; filePath?: string; startLine?: number; endLine?: number; documentation?: string }[]; + rootCount: number; + depth: number; + direction: 'both' | 'inbound' | 'outbound'; + edgeTypes: 'all' | string[]; + nodes: number; + edges: number; + state: 'complete' | 'loading' | 'partial'; + error?: string; + exhausted: boolean; + relationships: ScopeRelationships; +} + +const record = (value: unknown): Record | undefined => value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record : undefined; +const records = (value: unknown): Record[] => Array.isArray(value) ? value.map(record).filter(item => item !== undefined) : []; +const count = (value: unknown): number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0; +const line = (value: unknown): number | undefined => typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined; +function text(value: unknown, limit: number): string | undefined { + if (typeof value !== 'string' || !value.trim()) return undefined; + const clean = value.replace(/\s+/g, ' ').trim(); + return clean.length <= limit ? clean : `${clean.slice(0, limit - 1)}…`; +} + +function groups(value: unknown): RelationshipGroup[] { + return records(value).flatMap(group => { + const type = text(group.type, 60); + if (!type) return []; + const files = records(group.files).map(file => ({ path: text(file.path, 240) ?? '', symbols: records(file.symbols) + .flatMap(symbol => { const name = text(symbol.name, 120); return name ? [{ name, kind: text(symbol.kind, 40) }] : []; }) })) + .filter(file => file.symbols.length); + const listed = files.reduce((sum, file) => sum + file.symbols.length, 0); + return [{ type, count: Math.max(count(group.count), listed), files }]; + }); +} +const totals = (value: unknown) => records(value).flatMap(item => { const type = text(item.type, 60); return type ? [{ type, count: count(item.count) }] : []; }); + +export function readGalaxyEvidence(snapshot: string): GalaxyEvidence | undefined { + if (snapshot.length > 128_000) return undefined; + let parsed: Record | undefined; + try { parsed = record(JSON.parse(snapshot)); } catch { return undefined; } + const evidence = record(parsed?.evidence); + const relationships = record(evidence?.relationships); + if (evidence?.kind !== 'current-selection-evidence' || evidence.view !== 'galaxy' || !relationships + || !Array.isArray(relationships.incoming) || !Array.isArray(relationships.outgoing)) return undefined; + const selected = record(evidence.selected), scope = record(evidence.scope), limits = record(evidence.limitations); + const identity = record(selected?.scope); + const direction = scope?.direction === 'inbound' || scope?.direction === 'outbound' ? scope.direction : 'both'; + const edgeTypes = Array.isArray(scope?.edgeTypes) ? scope.edgeTypes.flatMap(type => text(type, 60) ?? []) : 'all'; + const state = limits?.state === 'complete-indexed-scope' ? 'complete' : limits?.state === 'loading-partial-preview' ? 'loading' : 'partial'; + const roots = records(selected?.roots).flatMap(root => { + const name = text(root.name, 120); + return name ? [{ name, kind: text(root.kind, 40), filePath: text(root.filePath, 240), startLine: line(root.startLine), + endLine: line(root.endLine), documentation: text(root.documentation, 300) }] : []; + }); + return { + project: text(evidence.project, 120) ?? '', label: text(identity?.name, 120) ?? roots[0]?.name ?? 'selection', + identity: identity ?? null, selectionKind: text(identity?.kind, 20) ?? 'node', + roots, rootCount: Math.max(count(selected?.rootCount), roots.length), + depth: count(scope?.depth), direction, edgeTypes, nodes: count(scope?.nodes), edges: count(scope?.edges), + state, error: text(limits?.error, 200), exhausted: limits?.exhausted === true, + relationships: { incoming: groups(relationships.incoming), incomingSymbols: count(relationships.incomingSymbols), + outgoing: groups(relationships.outgoing), outgoingSymbols: count(relationships.outgoingSymbols), + internal: totals(relationships.internal), beyond: totals(relationships.beyond) }, + }; +} + +/** Water-filling: parts that fit their fair share stay whole; larger parts split the rest. */ +export function fairShares(natural: readonly number[], budget: number): number[] { + const shares = natural.map(() => 0); + let pool = Math.max(0, Math.floor(budget)); + const order = natural.map((_, index) => index).sort((left, right) => natural[left] - natural[right]); + order.forEach((index, position) => { + shares[index] = Math.min(natural[index], Math.floor(pool / (order.length - position))); + pool -= shares[index]; + }); + return shares; +} + +/** One edge type: complete count, then names until the budget, then an explicit "+N more". */ +export function relationshipLine(group: RelationshipGroup, side: 'incoming' | 'outgoing', budget: number, + words: RelationshipWords, markdown = false): { text: string; listed: number } { + const quote = (value: string) => markdown ? `\`${value.replace(/`/g, "'")}\`` : value; + const head = `- ${markdown ? `**${group.type}**` : group.type} ${side === 'incoming' ? words.from : words.to} ${group.count}: `; + let body = '', listed = 0; + const more = (shown: number) => group.count > shown ? `${body ? '; ' : ''}${words.more(group.count - shown)}` : ''; + if (head.length + more(0).length > budget) return { text: '', listed: 0 }; + for (const file of group.files) { + const kinds = new Set(file.symbols.map(symbol => symbol.kind)); + const where = [kinds.size === 1 ? file.symbols[0].kind : undefined, file.path ? quote(file.path) : undefined].filter(Boolean).join(', '); + const suffix = where ? ` (${where})` : ''; + let chunk = ''; + for (const symbol of file.symbols) { + const next = `${chunk ? `${chunk}, ` : body ? '; ' : ''}${quote(symbol.name)}`; + const shown = listed + 1; + const after = group.count > shown ? `; ${words.more(group.count - shown)}` : ''; + if (head.length + body.length + next.length + suffix.length + after.length > budget) { + if (chunk) body += chunk + suffix; + return { text: head + body + more(listed), listed }; + } + chunk = next; listed = shown; + } + if (chunk) body += chunk + suffix; + } + return { text: head + body + more(listed), listed }; +} + +/** "1 hop in both directions, all relationship types; complete." in words, never as fields. */ +export function scopeSentence(evidence: GalaxyEvidence, words: RelationshipWords): string { + const direction = evidence.direction === 'inbound' ? words.inbound : evidence.direction === 'outbound' ? words.outbound : words.both; + const types = evidence.edgeTypes === 'all' ? words.allTypes : words.onlyTypes(evidence.edgeTypes); + const state = evidence.state === 'complete' ? words.complete : evidence.state === 'loading' ? words.loading : words.partial(evidence.error); + return words.scope(`${words.hops(evidence.depth)} ${direction}, ${types}`, words.size(evidence.nodes, evidence.edges), + evidence.state === 'complete' && evidence.exhausted ? `${state}; ${words.exhausted}` : state); +} + +/** Whether the loaded scope followed this side at all; otherwise "none" would be a guess. */ +export function sideLoaded(evidence: GalaxyEvidence, side: 'incoming' | 'outgoing'): boolean { + return evidence.depth > 0 && evidence.direction !== (side === 'incoming' ? 'outbound' : 'inbound'); +} + +export function selectionSentence(evidence: GalaxyEvidence): string[] { + const [first] = evidence.roots; + const range = (root: GalaxyEvidence['roots'][number]) => root.filePath + ? ` in ${root.filePath}${root.startLine ? `:${root.startLine}${root.endLine && root.endLine !== root.startLine ? `-${root.endLine}` : ''}` : ''}` : ''; + if (evidence.rootCount <= 1 && first) { + return [`Selected: ${first.name}${first.kind ? ` (${first.kind})` : ''}${range(first)}.`, + ...first.documentation ? [`Documentation: ${first.documentation}`] : []]; + } + if (!first) return [`Selected: ${evidence.label} (${evidence.selectionKind}); its symbols are not in the loaded scope yet.`]; + const listed = evidence.roots.map(root => `${root.name}${root.kind ? ` (${root.kind})` : ''}`).join(', '); + const omitted = evidence.rootCount - evidence.roots.length; + return [`Selected ${evidence.selectionKind}: ${evidence.label} with ${evidence.rootCount} symbols: ${listed}${omitted > 0 ? `; +${omitted} more` : ''}.`]; +} diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index fef7f9ab2..d0232192f 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -30,3 +30,32 @@ export const browserAiText = { progress: (loaded: number, total: number) => `${(loaded / 1_000_000).toFixed(1)} / ${(total / 1_000_000).toFixed(1)} MB`, location: (path: string, line: number) => `${path}:${line}`, }; + +/** Words for graph facts in the prompt (always English) and for the listed + * relationship answer, which follows the language of the question. */ +const englishRelationshipWords = { + from: 'from', to: 'to', + more: (count: number) => `+${count} more`, + hops: (depth: number) => depth === 0 ? 'the selection only' : depth === 1 ? '1 hop' : `${depth} hops`, + both: 'in both directions', inbound: 'incoming only', outbound: 'outgoing only', + allTypes: 'all relationship types', + onlyTypes: (types: readonly string[]) => types.length ? `only ${types.join(', ')}` : 'no relationship types', + size: (nodes: number, edges: number) => `${nodes} ${nodes === 1 ? 'symbol' : 'symbols'} and ${edges} ${edges === 1 ? 'relationship' : 'relationships'}`, + complete: 'complete for the indexed graph', + loading: 'still loading, so this is a partial preview', + partial: (error?: string) => `incomplete${error ? `: ${error}` : ''}`, + exhausted: 'nothing further beyond this depth', + scope: (shape: string, size: string, state: string) => `Scope: ${shape}; ${size}; ${state}.`, + incoming: (total: number, symbols: number) => `Incoming relationships: ${total} from ${symbols} ${symbols === 1 ? 'symbol' : 'symbols'}.`, + outgoing: (total: number, symbols: number) => `Outgoing relationships: ${total} to ${symbols} ${symbols === 1 ? 'symbol' : 'symbols'}.`, + noIncoming: 'Incoming relationships: none in this scope.', + noOutgoing: 'Outgoing relationships: none in this scope.', + incomingNotLoaded: 'Incoming relationships: not loaded; the scope does not follow incoming edges.', + outgoingNotLoaded: 'Outgoing relationships: not loaded; the scope does not follow outgoing edges.', + moreTypes: (count: number) => `+${count} more relationship ${count === 1 ? 'type' : 'types'}`, + internal: (summary: string) => `Between the selected symbols: ${summary}.`, + beyond: (summary: string) => `Further out in the scope: ${summary}.`, +}; +export type RelationshipWords = typeof englishRelationshipWords; + +export const relationshipWords: { en: RelationshipWords } = { en: englishRelationshipWords }; diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 252030257..2467352e6 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -143,7 +143,7 @@ import { useGraphScope } from './use-graph-scope'; import { limitGraphRender, scopedHierarchy } from './graph-scope'; import './graph-exploration.css'; import { layoutNodeForSelection } from './selected-node'; -import { graphNodeEvidence, useSelectionEvidence, type SelectionEvidenceListener } from './selection-evidence'; +import { graphNodeEvidence, scopeRelationships, useSelectionEvidence, type SelectionEvidenceListener } from './selection-evidence'; import { buildCoverageShadow } from './coverage-shadow'; import type { CoverageShadowNode } from './coverage-shadow'; import { @@ -1137,23 +1137,21 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const agentEvidence = useMemo(() => { if (!scope.scope) return undefined; const selected = scope.result?.data; - const roots = selected?.nodes.filter(node => scope.result?.roots.has(node.id)) ?? []; - const byId = new Map(selected?.nodes.map(node => [node.id, node])); + const rootIds = scope.result?.roots ?? new Set(); + const roots = selected?.nodes.filter(node => rootIds.has(node.id)) ?? []; const edges = selected?.edges ?? []; - const typeCounts: Record = {}; - edges.forEach(edge => { typeCounts[edge.type] = (typeCounts[edge.type] ?? 0) + 1; }); + // Every scope edge is classified against the roots first; only the names are + // bounded afterwards. Render budgets are a drawing concern and stay out of it. return { project, view: 'galaxy', source: 'query_graph scoped indexed relationships', label: scope.scope.name, - selected: { scope: scope.scope, rootCount: roots.length, roots: roots.slice(0, 24).map(graphNodeEvidence), omittedRoots: Math.max(0, roots.length - 24) }, + selected: { scope: scope.scope, rootCount: roots.length, roots: roots.slice(0, 8).map(graphNodeEvidence), omittedRoots: Math.max(0, roots.length - 8) }, scope: { depth: scope.depth, direction: props.workspaceExpanded ? scope.direction : 'both', edgeTypes: traceTypes ?? 'all', - nodes: selected?.nodes.length ?? 0, edges: edges.length, renderedNodes: shown?.nodes.length ?? 0, renderedEdges: shown?.edges.length ?? 0 }, - relationships: { typeCounts, items: edges.slice(0, 24).map(edge => ({ id: edge.id, type: edge.type, line: edge.line, - strategy: edge.strategy, confidence: edge.confidence, source: byId.get(edge.source) ? graphNodeEvidence(byId.get(edge.source)!) : { id: edge.source }, - target: byId.get(edge.target) ? graphNodeEvidence(byId.get(edge.target)!) : { id: edge.target } })), omitted: Math.max(0, edges.length - 24) }, + nodes: selected?.nodes.length ?? 0, edges: edges.length }, + relationships: scopeRelationships(selected?.nodes ?? [], edges, rootIds), limitations: { state: scope.complete ? 'complete-indexed-scope' : scope.loading ? 'loading-partial-preview' : 'partial', error: scope.error, exhausted: scope.result?.exhausted, indexCoverage: 'unavailable', - interpretation: 'Static indexed relationships, not runtime activity. Scope completeness is relative to the indexed graph and selected depth/types. Omitted snapshot examples do not mean absent relationships.' }, + interpretation: 'Static indexed relationships, not runtime activity. Scope completeness is relative to the indexed graph and selected depth/types.' }, }; - }, [project, scope.scope, scope.result, scope.depth, scope.direction, scope.complete, scope.loading, scope.error, traceTypes, props.workspaceExpanded, shown]); + }, [project, scope.scope, scope.result, scope.depth, scope.direction, scope.complete, scope.loading, scope.error, traceTypes, props.workspaceExpanded]); useSelectionEvidence(props.onSelectionEvidence, agentEvidence, visible && props.workspaceExpanded === true); const toggleKind = useCallback((type: string) => { if (props.workspaceExpanded && scope.scope) { diff --git a/graph-ui/src/galaxy/selection-evidence.test.tsx b/graph-ui/src/galaxy/selection-evidence.test.tsx index 206bcf557..1bd6e9e22 100644 --- a/graph-ui/src/galaxy/selection-evidence.test.tsx +++ b/graph-ui/src/galaxy/selection-evidence.test.tsx @@ -2,7 +2,7 @@ import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { expect, it, vi } from 'vitest'; -import { graphNodeEvidence, selectionEvidenceContext, useSelectionEvidence, type SelectionEvidence } from './selection-evidence'; +import { graphNodeEvidence, RELATED_NAMES_PER_GROUP, scopeRelationships, selectionEvidenceContext, useSelectionEvidence, type SelectionEvidence } from './selection-evidence'; const evidence: SelectionEvidence = { project: 'sample', view: 'galaxy', label: 'read', source: 'indexed graph', selected: { id: 7, qualifiedName: 'sample.read' }, limitations: 'Static evidence only.' }; @@ -28,6 +28,36 @@ it('retains source identity while excluding positions, visual size and color fro expect(selectionEvidenceContext({ ...evidence, project: 'another' }).id).not.toBe(selectionEvidenceContext(evidence).id); }); +it('classifies every scope edge by direction relative to the roots before bounding names', () => { + const node = (id: number, name: string, file: string) => ({ id, name, label: 'Function', file_path: file, x: 0, y: 0, z: 0, size: 1, color: '#999' }); + const callers = Array.from({ length: 30 }, (_, index) => node(100 + index, `caller${String(index).padStart(2, '0')}`, index < 20 ? 'dense.py' : 'sparse.py')); + const nodes = [node(1, 'root', 'root.py'), node(2, 'sibling', 'root.py'), node(3, 'callee', 'lib.py'), node(4, 'further', 'lib.py'), ...callers]; + const edges = [ + ...callers.map(caller => ({ source: caller.id, target: 1, type: 'CALLS' })), + { source: 1, target: 3, type: 'CALLS' }, { source: 1, target: 3, type: 'IMPORTS' }, { source: 1, target: 3, type: 'CALLS' }, + { source: 1, target: 2, type: 'CALLS' }, { source: 3, target: 4, type: 'CALLS' }, + ]; + const related = scopeRelationships(nodes, edges, new Set([1, 2])); + expect(related.incomingSymbols).toBe(30); + expect(related.incoming).toHaveLength(1); + expect(related.incoming[0]).toMatchObject({ type: 'CALLS', count: 30 }); + // Bounded names keep the densest file first and never drop the complete count. + const listed = related.incoming[0].files.flatMap(file => file.symbols); + expect(listed).toHaveLength(RELATED_NAMES_PER_GROUP); + expect(related.incoming[0].files[0]).toMatchObject({ path: 'dense.py' }); + expect(related.incoming[0].files[0].symbols).toHaveLength(20); + expect(related.outgoing).toEqual([ + { type: 'CALLS', count: 1, files: [{ path: 'lib.py', symbols: [{ name: 'callee', kind: 'Function' }] }] }, + { type: 'IMPORTS', count: 1, files: [{ path: 'lib.py', symbols: [{ name: 'callee', kind: 'Function' }] }] }, + ]); + expect(related.outgoingSymbols).toBe(1); + expect(related.internal).toEqual([{ type: 'CALLS', count: 1 }]); + expect(related.beyond).toEqual([{ type: 'CALLS', count: 1 }]); + // The classified snapshot survives the context bounds intact. + const context = selectionEvidenceContext({ ...evidence, relationships: related }); + expect(JSON.parse(context.text).evidence.relationships).toEqual(related); +}); + it('does not republish equivalent evidence or let hidden workspaces replace the active selection, and clears on background', async () => { (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; const host = document.createElement('div'), root = createRoot(host), listener = vi.fn(); diff --git a/graph-ui/src/galaxy/selection-evidence.ts b/graph-ui/src/galaxy/selection-evidence.ts index 9f4a5d492..bf1f6078b 100644 --- a/graph-ui/src/galaxy/selection-evidence.ts +++ b/graph-ui/src/galaxy/selection-evidence.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import type { BrowserChatContext } from '../browser-ai/chat-model'; -import type { GraphNode } from './types'; +import type { GraphEdge, GraphNode } from './types'; export type SelectionEvidenceListener = (context: BrowserChatContext | undefined) => void; export interface SelectionEvidence { @@ -23,6 +23,61 @@ export function graphNodeEvidence(node: GraphNode) { documentation: node.documentation, packageName: node.package_name }; } +/** Related symbols of one edge type and direction, grouped by file. `count` is + * complete; `files` lists at most `RELATED_NAMES_PER_GROUP` symbols. */ +export interface RelationshipGroup { type: string; count: number; files: { path: string; symbols: { name: string; kind?: string }[] }[] } +export interface ScopeRelationships { + /** Edges into a selected root from outside the selection. */ + incoming: RelationshipGroup[]; + incomingSymbols: number; + /** Edges from a selected root to outside the selection. */ + outgoing: RelationshipGroup[]; + outgoingSymbols: number; + /** Edge counts between selected roots and between symbols further out. */ + internal: { type: string; count: number }[]; + beyond: { type: string; count: number }[]; +} +export const RELATED_NAMES_PER_GROUP = 24; + +/** Classify every scope edge by its direction relative to the roots before any + * bound applies, so counts stay complete and callers never mix with callees. */ +export function scopeRelationships(nodes: readonly GraphNode[], edges: readonly GraphEdge[], roots: ReadonlySet): ScopeRelationships { + const byId = new Map(nodes.map(node => [node.id, node])); + const related = { incoming: new Map>(), outgoing: new Map>() }; + const counted = { internal: new Map(), beyond: new Map() }; + for (const edge of edges) { + const from = roots.has(edge.source), to = roots.has(edge.target); + if (from !== to) { + const side = to ? related.incoming : related.outgoing; + const members = side.get(edge.type) ?? new Set(); + members.add(to ? edge.source : edge.target); side.set(edge.type, members); + } else { + const side = from ? counted.internal : counted.beyond; + side.set(edge.type, (side.get(edge.type) ?? 0) + 1); + } + } + const groups = (side: Map>): RelationshipGroup[] => [...side].map(([type, members]) => { + const symbols = [...members].map(id => { + const node = byId.get(id); + return { name: node?.name ?? `#${id}`, kind: node?.label || undefined, path: node?.file_path ?? '' }; + }); + const perFile = new Map(); + symbols.forEach(symbol => perFile.set(symbol.path, (perFile.get(symbol.path) ?? 0) + 1)); + // Files with the most related symbols first, so a bounded list keeps the densest evidence. + symbols.sort((left, right) => perFile.get(right.path)! - perFile.get(left.path)! + || left.path.localeCompare(right.path) || left.name.localeCompare(right.name)); + const files = new Map(); + for (const { path, ...symbol } of symbols.slice(0, RELATED_NAMES_PER_GROUP)) files.set(path, [...files.get(path) ?? [], symbol]); + return { type, count: members.size, files: [...files].map(([path, listed]) => ({ path, symbols: listed })) }; + }).sort((left, right) => right.count - left.count || left.type.localeCompare(right.type)); + const distinct = (side: Map>) => new Set([...side.values()].flatMap(members => [...members])).size; + const totals = (side: Map) => [...side].map(([type, count]) => ({ type, count })) + .sort((left, right) => right.count - left.count || left.type.localeCompare(right.type)); + return { incoming: groups(related.incoming), incomingSymbols: distinct(related.incoming), + outgoing: groups(related.outgoing), outgoingSymbols: distinct(related.outgoing), + internal: totals(counted.internal), beyond: totals(counted.beyond) }; +} + /** Bound every collection/string and the total snapshot; report each omission. * Stable content identity prevents camera changes from triggering explanations. */ export function selectionEvidenceContext(evidence: SelectionEvidence): BrowserChatContext {