fix(ui): describe Galaxy selections to the local agent as readable facts

The prompt for a Galaxy selection was a dump of JSON paths: snapshot,
view, generation, rendered sizes and omission paths. Relationship items
were cut to 24 before anyone looked at their direction and then to three
before the budget applied, so "who calls JSONBAgg" reached the model with
one outgoing INHERITS edge and no caller.

GalaxyPanel now classifies every scope edge against the roots first and
publishes incoming and outgoing groups per edge type with complete
counts and the related names by file. The agent prompt states the
selection with kind and file:lines, both sides in words, the scope
(depth, direction, filters) and its completeness. Names are listed until
the budget ends and followed by an explicit "+N more"; the packet
reports how many it could name. Other evidence kinds keep the generic
formatter.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-02 18:43:44 +02:00
parent 2bc10e1616
commit c3fe308763
8 changed files with 437 additions and 28 deletions
@@ -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]) {
+70 -14
View File
@@ -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<string, unknown> | 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<string, unknown> | 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 } : {} };
}
@@ -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<number> } {
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.' } });
}
+145
View File
@@ -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<string, unknown> | undefined => value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown> : undefined;
const records = (value: unknown): Record<string, unknown>[] => 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<string, unknown> | 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` : ''}.`];
}
+29
View File
@@ -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 };
+10 -12
View File
@@ -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<number>();
const roots = selected?.nodes.filter(node => rootIds.has(node.id)) ?? [];
const edges = selected?.edges ?? [];
const typeCounts: Record<string, number> = {};
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) {
@@ -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<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const host = document.createElement('div'), root = createRoot(host), listener = vi.fn();
+56 -1
View File
@@ -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<number>): ScopeRelationships {
const byId = new Map(nodes.map(node => [node.id, node]));
const related = { incoming: new Map<string, Set<number>>(), outgoing: new Map<string, Set<number>>() };
const counted = { internal: new Map<string, number>(), beyond: new Map<string, number>() };
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<number>();
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<string, Set<number>>): 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<string, number>();
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<string, { name: string; kind?: string }[]>();
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<string, Set<number>>) => new Set([...side.values()].flatMap(members => [...members])).size;
const totals = (side: Map<string, number>) => [...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 {