Merge hand test fix: answer questions about the Galaxy view from its scope and catch restated answers (K45, K46)

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-04 18:42:10 +02:00
14 changed files with 570 additions and 21 deletions
+41 -7
View File
@@ -3,7 +3,7 @@ import type { BrowserAiProgress } from './browser-ai-controller';
import { createBrowserChatRuntime, type BrowserChatRuntime } from './browser-ai-runtime';
import { BROWSER_MODELS, getBrowserModel, isBrowserModelCached, removeBrowserModelCache, type BrowserModel } from './model-policy';
import { keepAgent, loadProgress, offerAgent, peekAgent, takeAgent, type AgentHandover, type LoadProgress } from './agent-handover';
import { buildChatMessages, selectionLocation, sentInHistory, snapshotAttachment, snapshotReaderContext, trimChatHistory, type BrowserChatAttachment, type BrowserChatContext, type BrowserChatReaderContext, type BrowserChatSource, type BrowserChatTurn } from './chat-model';
import { buildChatMessages, echoRetryRequest, selectionLocation, sentInHistory, snapshotAttachment, snapshotReaderContext, trimChatHistory, type BrowserChatAttachment, type BrowserChatContext, type BrowserChatReaderContext, type BrowserChatSource, type BrowserChatTurn } from './chat-model';
import { explanationInput, EXPLANATION_DELAY_MS, type ExplanationInput } from './proactive-selection';
import { prepareExplanationContext, selectionSummary, type PreparedExplanationContext } from './explanation-context';
import { AUTO_INPUT_TOKENS, AUTO_OUTPUT_TOKENS, citedInterpretation, explanationMode, explanationSentence, namesNotIn, parseExplanationResponse, explanationMessages, formatExplanationEvidence, type DroppedReason } from './explanation-response';
@@ -11,7 +11,7 @@ import { carriedSource, selectionSubject, SYMBOL_SOURCE_LINES, sourceTargetOf, s
import { codeFacts, codeFactsMarkdown, codeSourceFacts } from './code-facts';
import { relationshipAnswer, relationshipSuggestion } from './relationship-answer';
import { clampTokenLimits, tokenLimitBounds, tokenLimitsFor, useAgentPreferences, type TokenLimits } from './agent-preferences';
import { browserChatText, chatRound3Text, evidenceNote, groundedText, relationshipWords, topicText } from './strings';
import { browserChatText, chatRound3Text, evidenceNote, groundedText, relationshipWords, topicText, viewText } from './strings';
import { isGpuRuntimeFailure, BrowserRuntimeFatalError } from './runtime-fault';
import ChatMarkdown from './ChatMarkdown';
import AgentSettingsDialog from './AgentSettingsDialog';
@@ -20,7 +20,9 @@ import { chatTopic, contextFreeFollowUp, followedTopic, missingContextAnswer, qu
import { isDataFile, readerFacts } from './file-facts';
import { fileOutline } from './file-outline';
import { readGalaxyEvidence, type GalaxyEvidence } from './galaxy-evidence';
import { followUpAnswer, generalQuestion, knownNames, noQuestion, noQuestionAnswer, type ExampleSubject } from './question-intent';
import { followUpAnswer, generalQuestion, knownNames, noQuestion, noQuestionAnswer, viewQuestion, type ExampleSubject } from './question-intent';
import { viewAnswer } from './view-answer';
import { echoAnswer } from './echo-answer';
import './browser-chat.css';
export type { BrowserChatAttachment, BrowserChatContext, BrowserChatReaderContext, BrowserChatSource } from './chat-model';
@@ -90,6 +92,17 @@ function groundedExplanation(summary: readonly string[], sentence: string | unde
const note = sentence ? words.factsAndSentence : dropped ? words.sentenceDropped(reason) : words.factsOnly;
return [summary.map(line => `- ${line}`).join('\n'), ...code ? [code] : [], ...sentence ? [sentence] : [], `_${note}_`].join('\n\n');
}
/** What a turn says when the model only restated the question: that it gave no answer, then the
* facts of the selection or open file the question was about, as a general question lists them (H2). */
function echoedAnswer(output: string, language: 'en' | 'de', graph: BrowserChatContext | undefined, reader: BrowserChatReaderContext | undefined): string {
const text = viewText[language];
const echoed = output.replace(/\s+/g, ' ').trim();
const shown = echoed.length > 80 ? `${echoed.slice(0, 79)}…` : echoed;
const source = reader?.source;
const summary = graph ? selectionSummary(graph, language) : source ? isDataFile(source.path) ? readerFacts(reader) : codeSourceFacts(source, language).summary : [];
if (!summary.length) return `${text.noAnswer(shown)} ${text.rephrase}`;
return `${text.noAnswer(shown)} ${text.factsFollow}\n\n${groundedExplanation(summary, undefined, false, graph ? 'graph' : 'file', language)}`;
}
/** 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') : '';
@@ -663,6 +676,17 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
const language = questionLanguage(prompt);
const galaxy = currentGraph[0] ? readGalaxyEvidence(currentGraph[0].text) : undefined;
const fileSource = reader?.source;
// A question about the current view ("erkläre die aktuelle Hierarchie", "what am I looking at")
// is answered from the loaded scope: the model only restated it (H1).
const view = !retry && !source && !extra.length && galaxy && viewQuestion(prompt, galaxyNames(galaxy)) === 'general' ? viewAnswer(galaxy, language) : undefined;
if (view) {
const id = `local-turn-${crypto.randomUUID()}`, scope = currentGraph[0];
setTurns(previous => [...previous, { id, prompt, context: extra, topic, listedFrom: scope, replyLanguage: language,
evidence: prepareExplanationContext(undefined, scope, chatEvidence, knownSource(scope)), modelId: model.id, request: [],
answer: view, status: 'complete', answeredFrom: 'graph' }]);
consume(); listSource(id, scope);
return;
}
// A prompt that asks nothing ("test", "hallo") gets questions it could ask, not an echo of the model (C6).
const known = knownNames({ galaxy, names: galaxy ? galaxyNames(galaxy) : fileSource ? [fileSource.path] : [],
texts: [fileSource?.text ?? '', source?.text ?? '', ...currentGraph.filter(() => !galaxy).map(item => item.label), ...extra.map(item => item.label)] });
@@ -726,7 +750,8 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
consume();
return;
}
let request = grounded ? explanationMessages(packet!, grounded.subject, language) : retry && !ask ? retry.request.map(message => ({ ...message })) : makeRequest();
// An echo asked again is told not to restate the question: the same request gets the same echo (H2).
let request = grounded ? explanationMessages(packet!, grounded.subject, language) : retry && !ask ? retry.echo ? echoRetryRequest(retry.request) : retry.request.map(message => ({ ...message })) : makeRequest();
if (automaticRun) {
automaticRun.cancelled = true; lastAttempt.current = undefined;
setQuestionQueued(true); currentRuntime.stop();
@@ -791,10 +816,19 @@ 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.reason ?? checked.dropped !== undefined, grounded.from, language, code);
// A sentence that only restates the question says nothing beside the facts (H2).
const echoed = checked.sentence !== undefined && echoAnswer(checked.sentence, prompt);
const answer = groundedExplanation(grounded.summary, echoed ? undefined : checked.sentence, checked.reason ?? (echoed || checked.dropped !== undefined), grounded.from, language, code);
setTurns(previous => previous.map(item => item.id === id ? { ...item, answer, status: stopRequested.current ? 'stopped' : 'complete' } : item));
return;
}
// An answer that only restates the question is none: the turn says so, with the facts it was about (H2).
if (!stopRequested.current && echoAnswer(output, prompt)) {
const about = packetGraph[0] ?? retry?.listedFrom;
setTurns(previous => previous.map(item => item.id === id ? { ...item, answer: echoedAnswer(output, language, about, about ? undefined : reader), status: 'complete', echo: true,
replyLanguage: language, ...about ? { listedFrom: about } : {} } : item));
return;
}
setTurns(previous => previous.map(item => item.id === id ? { ...item, answer: stopRequested.current ? item.answer : output, status: stopRequested.current ? 'stopped' : 'complete',
...shortened && !stopRequested.current ? { shortened, limit: { inputTokens: limits.inputTokens, outputTokens: limits.outputTokens } } : {} } : item));
} catch (failure) {
@@ -933,8 +967,8 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
</> : null}
</SourceDisclosure><div className="cbm-chat-answer-text"><ChatMarkdown text={turn.answer || (turn.status === 'generating' ? 'Thinking…' : turn.status === 'stopped' ? 'Stopped before an answer.' : '')} /></div>
{turn.status === 'stopped' && turn.answer && <small>Stopped · partial answer</small>}
{!turn.answeredFrom && turn.answer && (turn.status === 'complete' || turn.status === 'stopped') && <small className="cbm-chat-model-note">{ownWords.modelNote}</small>}
{turn.status !== 'generating' && !turn.answeredFrom && <AnswerNotes shortened={limitNote(turn.shortened, turn.limit)} packet={turn.evidence}
{!turn.answeredFrom && !turn.echo && turn.answer && (turn.status === 'complete' || turn.status === 'stopped') && <small className="cbm-chat-model-note">{ownWords.modelNote}</small>}
{turn.status !== 'generating' && !turn.answeredFrom && !turn.echo && <AnswerNotes shortened={limitNote(turn.shortened, turn.limit)} packet={turn.evidence}
unsupported={turn.answer ? namesNotIn(turn.answer, turn.request.map(message => message.content).join('\n')) : []} model={BROWSER_MODELS.find(candidate => candidate.id === turn.modelId)?.displayName ?? turn.modelId} historyOmitted={turn.historyOmitted} />}
{turn.status === 'error' && <p className="cbm-chat-turn-error" role="alert">{turn.error}</p>}
{index === turns.length - 1 && turn.answeredFrom === 'suggestion' && turn.suggestion && <button type="button" className="cbm-chat-retry"
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { githubFolderEvidence, jsonbAggEvidence } from './galaxy-evidence.fixture';
import { dockHarness } from './chat-dock.fixture';
const dock = dockHarness();
/** With .github selected in the Galaxy hierarchy, "kkannst du mir die heirarchie erklären" and
* "erkläre die aktuelle hierarchy" went to the free model, which answered "Ich kann dir die
* Heirarchy erklären." and "Die aktuelle Hierarchie erklärt." (hand test of 2026-10-04, H1, H2). */
describe('questions about the current view are answered from the loaded scope (H1)', () => {
it('lists the middle, the left and the right of the hierarchy in German, without the model', async () => {
const { props, runtime } = dock.setup();
await dock.render({ ...props, proactiveSelection: githubFolderEvidence('hierarchy') }); await dock.load();
for (const question of ['kkannst du mir die heirarchie erklären', 'erkläre die aktuelle hierarchy']) {
await dock.ask(question);
const answer = dock.answerOf(dock.last()).replace(/\s+/g, ' ');
expect(answer).toContain('.github (Ordner) steht in der Mitte; der Ausschnitt geht 1 Schritt in beide Richtungen.');
expect(answer).toContain('Links, eingehend: CONTAINS_FOLDER (1): DETACHED');
expect(answer).toContain('Rechts, ausgehend: CONTAINS_FILE (4): CODE_OF_CONDUCT.md, FUNDING.yml, pull_request_template.md, SECURITY.md CONTAINS_FOLDER (1): workflows');
expect(answer).toContain('So liest du die Hierarchie: eingehende Beziehungen stehen links, ausgehende rechts.');
expect(answer).toContain('Aus dem indizierten Graphen gelistet, nicht vom Modell erzeugt.');
expect(dock.buttonsOf(dock.last())).toEqual(['Modell fragen']);
expect(dock.notesOf(dock.last())).toEqual([]);
}
expect(runtime.countTokens).not.toHaveBeenCalled();
expect(runtime.chat).not.toHaveBeenCalled();
// "Modell fragen" asks the model the same question about the same scope, below the list.
runtime.chat.mockResolvedValueOnce('Die Hierarchie zeigt `.github` mit vier Dateien und dem Ordner `workflows`.');
await dock.click('Modell fragen', dock.last());
expect(runtime.chat).toHaveBeenCalledOnce();
expect(runtime.chat.mock.calls[0][0].at(-1)!.content).toContain('erkläre die aktuelle hierarchy');
expect(dock.turns()).toHaveLength(3);
expect(dock.answerOf(dock.last())).toBe('Die Hierarchie zeigt .github mit vier Dateien und dem Ordner workflows.');
});
it('says in English that the galaxy view shows the same scope as a cloud', async () => {
const { props, runtime } = dock.setup();
await dock.render({ ...props, proactiveSelection: githubFolderEvidence('galaxy') }); await dock.load();
await dock.ask('what does this view show?');
const answer = dock.answerOf(dock.last()).replace(/\s+/g, ' ');
expect(answer).toContain('.github (Folder) is in the middle; the scope reaches 1 hop in both directions.');
expect(answer).toContain('Outgoing: CONTAINS_FILE (4)');
expect(answer).toContain('The galaxy view shows this scope as a cloud.');
expect(answer).not.toContain('Links');
expect(dock.buttonsOf(dock.last())).toEqual(['Ask the model']);
expect(runtime.chat).not.toHaveBeenCalled();
});
});
describe('a model answer that only restates the question is not shown as an answer (H2)', () => {
it('says the model gave no answer, lists the facts of the selection and offers to ask again', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('Ich kann dir die Ordner erklären.').mockResolvedValueOnce('Weil GitHub dort Vorlagen und Workflows sucht.');
await dock.render({ ...props, proactiveSelection: githubFolderEvidence('hierarchy') }); await dock.load();
await dock.ask('kannst du mir die ordner erklären, die es hier gibt?');
expect(runtime.chat).toHaveBeenCalledOnce();
const answer = dock.answerOf(dock.last());
expect(answer).toContain('Das Modell hat keine Antwort gegeben, es hat nur die Frage wiederholt („Ich kann dir die Ordner erklären.“).');
expect(answer).toContain('Ausgewählt: .github (Ordner) in .github.');
expect(answer).toContain('Ausgehend: 5 Beziehungen zu 5 Symbolen (CONTAINS_FILE 4, CONTAINS_FOLDER 1).');
expect(answer).toContain('Aus dem indizierten Graphen gelistet, nicht vom Modell erzeugt.');
expect(dock.notesOf(dock.last())).toEqual([]);
expect(dock.buttonsOf(dock.last())).toEqual(['Erneut fragen']);
// Asked again, the model is told not to restate the question; its real answer replaces the note.
await dock.click('Erneut fragen', dock.last());
expect(runtime.chat).toHaveBeenCalledTimes(2);
expect(runtime.chat.mock.calls[1][0].at(-1)!.content).toContain('Do not restate the question.');
expect(dock.turns()).toHaveLength(1);
expect(dock.answerOf(dock.last())).toBe('Weil GitHub dort Vorlagen und Workflows sucht.');
expect(dock.notesOf(dock.last())).toContain('Vom lokalen Modell erzeugt; kann falsch sein.');
});
it('keeps a real short answer', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('Nein, 4.').mockResolvedValueOnce('Ja, 11.');
await dock.render({ ...props, proactiveSelection: githubFolderEvidence('hierarchy') }); await dock.load();
await dock.ask('Sind es mehr als zehn Dateien?');
expect(dock.answerOf(dock.last())).toBe('Nein, 4.');
await dock.ask('Wie viele Tests hat das Projekt ungefähr?');
expect(dock.answerOf(dock.last())).toBe('Ja, 11.');
expect(dock.notesOf(dock.last())).toContain('Vom lokalen Modell erzeugt; kann falsch sein.');
});
it('leaves out a restating sentence under the facts of a general question', async () => {
const { props, runtime } = dock.setup();
runtime.chat.mockResolvedValueOnce('Ich erkläre dir das.');
await dock.render({ ...props, proactiveSelection: jsonbAggEvidence() }); await dock.load();
await dock.ask('erklär mir das');
expect(runtime.chat).toHaveBeenCalledOnce();
const answer = dock.answerOf(dock.last());
expect(answer).toContain('Ausgewählt: JSONBAgg (Klasse)');
expect(answer).not.toContain('Ich erkläre dir das.');
expect(answer).toContain('Der Satz des Modells wurde weggelassen');
});
});
+15 -2
View File
@@ -64,6 +64,9 @@ export interface BrowserChatTurn {
/** The model's answer to a question the chat had answered itself ("Ask the model"). It
* stands below that answer instead of replacing it: the listed facts stay (B1). */
askedModel?: boolean;
/** The model only restated the question: the turn says so and lists the facts of the selection
* instead (H2). Asked again, the model is told not to restate it. */
echo?: boolean;
}
export interface BrowserChatContext {
@@ -166,9 +169,19 @@ export function historyAnswer(turn: BrowserChatTurn): string {
}
/** Whether a turn goes into the history of a later request: an answer, not a suggestion,
* the chat's own hint or its reply that a question has no context. */
* the chat's own hint, its reply that a question has no context, or a restated question (H2). */
export const sentInHistory = (turn: BrowserChatTurn): boolean => turn.status !== 'error' && turn.status !== 'generating'
&& turn.answeredFrom !== 'suggestion' && turn.answeredFrom !== 'local' && turn.answeredFrom !== 'hint';
&& turn.answeredFrom !== 'suggestion' && turn.answeredFrom !== 'local' && turn.answeredFrom !== 'hint' && !turn.echo;
/** Added to a question asked again after the model only restated it: the same request got the same
* echo, the model does not sample (H2). */
export const ECHO_RETRY = 'Your last answer only restated the question. Do not restate the question. Answer it with the facts of the evidence, or name the fact that is missing.';
/** The request of a question asked again after an echo: the last user message carries ECHO_RETRY once. */
export function echoRetryRequest(request: readonly BrowserChatMessage[]): BrowserChatMessage[] {
const last = request.length - 1;
return request.map((message, index) => index === last && message.role === 'user' && !message.content.includes(ECHO_RETRY)
? { ...message, content: `${message.content}\n\n${ECHO_RETRY}` } : { ...message });
}
export function buildChatMessages(turns: readonly BrowserChatTurn[], prompt: string, attachment?: BrowserChatAttachment, context: readonly BrowserChatContext[] = [], readerContext?: BrowserChatReaderContext, currentContext: readonly BrowserChatContext[] = [], currentEvidence?: string,
language?: keyof typeof ANSWER_LANGUAGE): BrowserChatMessage[] {
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { echoAnswer } from './echo-answer';
/** "Ich kann dir die Heirarchy erklären." and "Die aktuelle Hierarchie erklärt." were shown as
* answers: they hold nothing beyond the words of the question (hand test of 2026-10-04, H2). */
describe('answers that only restate the question (H2)', () => {
it.each([
['kkannst du mir die heirarchie erklären', 'Ich kann dir die Heirarchy erklären.'],
['erkläre die aktuelle hierarchy', 'Die aktuelle Hierarchie erklärt.'],
['erkläre die aktuelle hierarchy', 'Gerne erkläre ich dir die aktuelle Hierarchie!'],
['erkläre die aktuelle hierarchy', 'Ich kann dir die aktuelle Struktur des Projekts erklären.'],
['explain the hierarchy', 'I can explain the hierarchy.'],
['explain the hierarchy', 'Sure, I can explain the current hierarchy for you.'],
['what does this view show?', 'This view shows the view.'],
['erklär mir das', 'Ich erkläre dir das.'],
['Was zeigt diese Ansicht?', 'Ja, ich kann dir zeigen, was diese Ansicht zeigt.'],
])('finds no answer to %s in %s', (question, answer) => {
expect(echoAnswer(answer, question)).toBe(true);
});
it.each([
['Wie viele Aufrufer hat JSONBAgg?', 'Ja, 11.'],
['Hat JSONBAgg 11 Aufrufer?', 'Ja, 11.'],
['Ist .github ein Ordner?', 'Ja.'],
['Is this a class?', 'No.'],
['Wie viele Dateien enthält .github?', '3 Dateien enthält .github.'],
['was ist .github?', 'Das ist ein Ordner.'],
['what is JSONBAgg?', 'An aggregate for PostgreSQL.'],
['erkläre die aktuelle hierarchy', 'Die Hierarchie zeigt `.github` in der Mitte, links `DETACHED` und rechts vier Dateien und den Ordner `workflows`.'],
['explain the hierarchy', 'I can explain it: `.github` contains four files and the `workflows` folder.'],
['erkläre die aktuelle hierarchy', ''],
])('keeps the answer to %s: %s', (question, answer) => {
expect(echoAnswer(answer, question)).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { typoBudget, withinEdits } from './relationship-answer';
/** Whether a model answer only restates its question (H2).
*
* With .github selected, "kkannst du mir die heirarchie erklären" got "Ich kann dir die Heirarchy
* erklären." and "erkläre die aktuelle hierarchy" got "Die aktuelle Hierarchie erklärt.": nothing
* but the words of the question. Such an answer is no answer. A short real one stays: "Ja, 11."
* answers a count, "Ja." or "No." a yes or no question, "Das ist ein Ordner." a "was ist". */
/** Words that carry nothing of their own in either language: pronouns, articles, auxiliaries, politeness. */
const STOP = new Set([
'ich', 'du', 'er', 'sie', 'es', 'wir', 'ihr', 'mir', 'dir', 'mich', 'dich', 'uns', 'euch', 'ihm', 'ihn', 'ihnen', 'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine',
'einen', 'einem', 'einer', 'eines', 'und', 'oder', 'aber', 'ist', 'sind', 'war', 'bin', 'bist', 'hat', 'habe', 'hast', 'haben', 'kann', 'kannst', 'können', 'könnte',
'könntest', 'will', 'werde', 'wird', 'werden', 'gerne', 'gern', 'natürlich', 'klar', 'sicher', 'hier', 'da', 'dies', 'diese', 'dieser', 'dieses', 'diesen', 'diesem',
'mal', 'bitte', 'so', 'auch', 'noch', 'nur', 'zu', 'mit', 'von', 'für', 'auf', 'in', 'im', 'an', 'am', 'um', 'was', 'wie', 'wo', 'wer', 'dass', 'jetzt', 'gerade', 'dazu',
'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'my', 'your', 'us', 'the', 'a', 'an', 'this', 'that', 'these', 'those', 'is', 'are', 'was', 'be', 'am', 'can', 'could',
'will', 'would', 'shall', 'should', 'do', 'does', 'did', 'of', 'to', 'for', 'in', 'on', 'at', 'by', 'with', 'and', 'or', 'sure', 'certainly', 'course', 'happy', 'glad',
'here', 'there', 'let', 'what', 'how', 'ok', 'okay', 'please', 'so', 'just', 'now',
]);
/** A reply to a yes or no question. */
const YES_NO = new Set(['ja', 'nein', 'doch', 'yes', 'no', 'nope', 'yep']);
/** "I can explain X", "Ich kann dir X erklären", "Gerne erkläre ich ...": an offer, not an answer. */
const RESTATEMENT = /(?:^|[^\p{L}])(?:i can|i could|i will|i'll|i would|i'd|let me|happy to|glad to|ich kann|kann ich|ich könnte|könnte ich|ich werde|werde ich|ich erkläre|erkläre ich|gerne|gern)(?=[^\p{L}]|$)/iu;
/** Words a restatement may add without saying anything: "die aktuelle Struktur des Projekts". */
const RESTATED_WORDS = 3;
const wordsOf = (text: string): string[] => (text.match(/`[^`]+`|[\p{L}\p{N}_][\p{L}\p{N}_.:/-]*/gu) ?? []).map(word => word.replace(/[.:/-]+$/, '')).filter(Boolean);
/** A name, path or number: something the question did not say is information, however short the answer. */
const codeShaped = (word: string): boolean => /^`|\p{N}|_|[./:]|\p{Ll}\p{Lu}|\p{Lu}{2}\p{Ll}/u.test(word);
/** The same word, mistyped or inflected: "Heirarchy" for "heirarchie", "erklärt" for "erkläre". */
function same(left: string, right: string): boolean {
if (left === right) return true;
const longer = left.length >= right.length ? left : right, shorter = longer === left ? right : left;
if (shorter.length >= 4 && withinEdits(left, right, typoBudget(longer))) return true;
let prefix = 0;
while (prefix < shorter.length && left[prefix] === right[prefix]) prefix++;
return prefix >= 4 && prefix >= shorter.length - 2;
}
export function echoAnswer(answer: string, question: string): boolean {
const words = wordsOf(answer);
if (!words.length) return false;
const asked = wordsOf(question).map(word => word.toLowerCase().replace(/`/g, ''));
const content = words.filter(word => {
const lower = word.toLowerCase().replace(/`/g, '');
return !STOP.has(lower) && !YES_NO.has(lower) && !asked.some(other => same(lower, other));
});
if (content.some(codeShaped)) return false;
if (RESTATEMENT.test(answer)) return content.length < RESTATED_WORDS;
return content.length === 0 && !words.some(word => YES_NO.has(word.toLowerCase()));
}
@@ -59,3 +59,19 @@ export function largeFolderScope(): SelectionEvidence {
return galaxyScopeEvidence({ project: 'django-demo', identity: { kind: 'folder', path: 'django/contrib/postgres', name: 'postgres' }, nodes, edges,
roots: new Set(roots.map(root => root.id)), depth: 1, direction: 'both', edgeTypes: 'all', state: 'complete-indexed-scope', exhausted: false });
}
/** `.github` in django-demo at one layer, as the hand test of 2026-10-04 showed it in the hierarchy:
* the branch node of the detached checkout above it, four files and the workflows folder below it. */
export function githubFolderEvidence(display?: 'galaxy' | 'hierarchy', overrides: { depth?: number; direction?: string; nodes?: GraphNode[]; edges?: GraphEdge[] } = {}): BrowserChatContext {
const folder = (id: number, name: string, path: string): GraphNode => ({ id, name, label: 'Folder', file_path: path, qualified_name: `django-demo.${path}`, x: 0, y: 0, z: 0, size: 1, color: '#999' });
const file = (id: number, name: string): GraphNode => ({ id, name, label: 'File', file_path: `.github/${name}`, qualified_name: `django-demo..github.${name}`, x: 0, y: 0, z: 0, size: 1, color: '#999' });
const root = folder(1, '.github', '.github');
const branch: GraphNode = { id: 2, name: 'DETACHED', label: 'Branch', qualified_name: 'django-demo.__branch__.detached', x: 0, y: 0, z: 0, size: 1, color: '#999' };
const files = ['CODE_OF_CONDUCT.md', 'FUNDING.yml', 'pull_request_template.md', 'SECURITY.md'].map((name, index) => file(10 + index, name));
const workflows = folder(20, 'workflows', '.github/workflows');
const edges: GraphEdge[] = [{ source: branch.id, target: root.id, type: 'CONTAINS_FOLDER' }, ...files.map(item => ({ source: root.id, target: item.id, type: 'CONTAINS_FILE' })),
{ source: root.id, target: workflows.id, type: 'CONTAINS_FOLDER' }];
return selectionEvidenceContext(galaxyScopeEvidence({ project: 'django-demo', identity: { kind: 'folder', path: '.github', name: '.github' },
nodes: [root, branch, ...files, workflows, ...overrides.nodes ?? []], edges: [...edges, ...overrides.edges ?? []], roots: new Set([root.id]),
depth: overrides.depth ?? 1, direction: overrides.direction ?? 'both', edgeTypes: 'all', state: 'complete-indexed-scope', exhausted: false, ...display ? { display } : {} }));
}
+12 -4
View File
@@ -27,6 +27,8 @@ export interface GalaxyEvidence {
relationships: Omit<ScopeRelationships, 'incomingSymbols' | 'outgoingSymbols'> & { incomingSymbols?: number; outgoingSymbols?: number };
/** The snapshot budget cut relationship data, so counts and names can be incomplete. */
truncated: boolean;
/** Which picture of the scope Galaxy shows; undefined where the snapshot does not say (H1). */
display?: 'galaxy' | 'hierarchy';
}
const record = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -93,7 +95,7 @@ export function readGalaxyEvidence(snapshot: string): GalaxyEvidence | undefined
relationships: { incoming: groups(relationships?.incoming, project), incomingSymbols: total(relationships?.incomingSymbols),
outgoing: groups(relationships?.outgoing, project), outgoingSymbols: total(relationships?.outgoingSymbols),
internal: totals(relationships?.internal), beyond: totals(relationships?.beyond) },
truncated,
truncated, ...scope.display === 'galaxy' || scope.display === 'hierarchy' ? { display: scope.display } : {},
};
}
@@ -141,15 +143,21 @@ export function relationshipLine(group: RelationshipGroup, _side: 'incoming' | '
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 {
/** The direction, the edge types and how complete the scope is, in words. */
export function scopeParts(evidence: GalaxyEvidence, words: RelationshipWords): { direction: string; types: string; state: 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
: evidence.state === 'limited' && evidence.renderLimit ? words.renderLimited(evidence.renderLimit.layer, evidence.renderLimit.limit, evidence.renderLimit.kind)
: words.partial(evidence.error);
const notes = [state, ...evidence.state === 'complete' && evidence.exhausted ? [words.exhausted] : [], ...evidence.truncated ? [words.truncated] : []];
return words.scope(`${words.hops(evidence.depth)} ${direction}, ${types}`, words.size(evidence.nodes, evidence.edges), notes.join('; '));
return { direction, types, state: notes.join('; ') };
}
/** "1 hop in both directions, all relationship types; complete." in words, never as fields. */
export function scopeSentence(evidence: GalaxyEvidence, words: RelationshipWords): string {
const { direction, types, state } = scopeParts(evidence, words);
return words.scope(`${words.hops(evidence.depth)} ${direction}, ${types}`, words.size(evidence.nodes, evidence.edges), state);
}
/** Whether the loaded scope followed this side at all; otherwise "none" would be a guess. */
+48 -3
View File
@@ -42,13 +42,46 @@ const GENERAL = [
/** Asking for detail: the model answers these as before. */
const DETAIL = /(?:^| )(?:in details?|detailed|detailliert\S*|ausführlich\S*|line by line|zeile für zeile|step by step|schritt für schritt|genau(?= erklär)|im detail|in depth|thoroughly|every line|jede zeile)(?= |$)/gu;
/** What the current view is called: "die Hierarchie", "this view", "den Graphen" (H1). */
const VIEW = 'hierarchie|hierarchy|ansicht|view|graph|graphen|graphs|ausschnitt|struktur|structure|darstellung|diagramm|diagram|scope|bild|picture|galaxy|galaxie|layout';
const VIEW_ADJ = `${ADJ}|gezeigte|gezeigten|angezeigte|angezeigten|shown|displayed`;
/** The view: "die aktuelle Hierarchie", "this graph", "den Ausschnitt hier", also of the selection ("von .github"). */
const VIEW_OBJECT = `(?:(?:${DET}) )?(?:(?:${VIEW_ADJ}) )?(?:${VIEW})(?: (?:hier|here|da|there))?(?: (?:von|zu|für|of|for) <name>)?`;
const VIEW_QUESTIONS = [
// German
String.raw`^(?:erklär|erkläre|erklären|erklärst|beschreib|beschreibe)(?: (?:mir|uns))?(?: bitte)?(?: mal)?(?: kurz)? ${VIEW_OBJECT}`,
String.raw`^(?:kannst|könntest) du (?:(?:mir|uns) )?(?:bitte )?(?:mal )?${VIEW_OBJECT}(?: (?:erklären|beschreiben|zeigen|zusammenfassen))?`,
String.raw`^was (?:zeigt|zeigen|bedeutet|bedeuten|ist|enthält) (?:(?:mir|uns) )?${VIEW_OBJECT}`,
String.raw`^was (?:sehe ich|sieht man|sehen wir|siehst du)(?: (?:hier|da|gerade|jetzt))*(?: (?:in|auf) ${VIEW_OBJECT})?`,
String.raw`^was (?:ist|wird) (?:(?:mir|uns) )?(?:hier|da) (?:zu sehen|dargestellt|gezeigt|angezeigt)`,
String.raw`^wie (?:lese|liest|lesen) (?:ich|man|wir) ${VIEW_OBJECT}`,
String.raw`^worum geht es (?:in|bei) ${VIEW_OBJECT}`,
// English
String.raw`^(?:explain|describe|summarize|summarise)(?: to me)? ${VIEW_OBJECT}`,
String.raw`^(?:can|could|would) you (?:please )?(?:explain|describe|summarize|summarise)(?: to me)? ${VIEW_OBJECT}`,
String.raw`^what (?:does|do) ${VIEW_OBJECT} (?:show|mean|display|contain|tell (?:me|us))`,
String.raw`^what (?:is|are) (?:shown|displayed|visible) (?:here|in ${VIEW_OBJECT})`,
String.raw`^what is ${VIEW_OBJECT}(?: (?:about|showing))?`,
String.raw`^what am i (?:looking at|seeing)(?: (?:here|now))?`,
String.raw`^what do (?:i|we) see(?: (?:here|now))?`,
String.raw`^how (?:do|should|can) (?:i|we) read ${VIEW_OBJECT}`,
String.raw`^tell (?:me|us) about ${VIEW_OBJECT}`,
// Only the view: "Hierarchie?"
String.raw`^${VIEW_OBJECT}`,
].map(source => new RegExp(source + END, 'u'));
/** Words a typo is corrected to, as K16 does for "wer ruf": "kansnt" is "kannst". */
const VOCABULARY = ['kannst', 'könntest', 'sagen', 'macht', 'klasse', 'funktion', 'methode', 'datei', 'erklär', 'erkläre', 'erklären', 'beschreib', 'beschreibe',
'diese', 'dieser', 'dieses', 'diesen', 'diesem', 'über', 'erzählen', 'explain', 'describe', 'summarize', 'about', 'tell', 'what', 'does', 'this', 'that',
'code', 'class', 'function', 'method', 'file', 'symbol', 'modul', 'module', 'zusammen', 'bedeutet', 'enthält', 'contain'];
/** The view as it is mistyped: "heirarchie", "heirachie", "hierachy", "ansciht" (H1). Long words take two typos, as names do. */
const VIEW_VOCABULARY = ['hierarchie', 'hierarchy', 'ansicht', 'ausschnitt', 'struktur', 'structure', 'graphen', 'darstellung', 'diagramm'];
/** Real words a typo away from a view word; they are never "corrected" into one. */
const NOT_VIEW = ['absicht', 'einsicht', 'aussicht', 'gruppen'];
/** Words of the patterns themselves: never "corrected" into another one ("dies" is no "does"). */
const OWN = new Set([...[DET, ADJ, KIND, PRONOUN, TAIL].flatMap(list => list.split('|')), ...VOCABULARY, 'was', 'wass', 'tut', 'ist', 'kann', 'man', 'dazu', 'darüber',
'steht', 'passiert', 'worum', 'geht', 'bei', 'can', 'could', 'you', 'more', 'do', 'is', 'to', 'how', 'why', 'with', 'from', 'calls', 'call', 'used', 'uses']);
const OWN = new Set([...[DET, ADJ, KIND, PRONOUN, TAIL, VIEW, VIEW_ADJ].flatMap(list => list.split('|')), ...VOCABULARY, ...NOT_VIEW, 'was', 'wass', 'tut', 'ist', 'kann', 'man', 'dazu', 'darüber',
'steht', 'passiert', 'worum', 'geht', 'bei', 'can', 'could', 'you', 'more', 'do', 'is', 'to', 'how', 'why', 'with', 'from', 'calls', 'call', 'used', 'uses',
'zeigt', 'zeigen', 'sehe', 'sieht', 'sehen', 'siehst', 'lese', 'liest', 'lesen', 'show', 'shows', 'read', 'see', 'looking', 'seeing', 'visible']);
const SPELLED = { erklaer: 'erklär', erklaere: 'erkläre', erklaeren: 'erklären', ueber: 'über', fuer: 'für', ausfuehrlich: 'ausführlich', koenntest: 'könntest', enthaelt: 'enthält' } as Record<string, string>;
/** The prompt in lower case, words corrected, the selection's name as `<name>`. */
@@ -62,7 +95,8 @@ function normalize(prompt: string, names: readonly string[]): string {
if (OWN.has(spelled)) return spelled;
if (own.some(name => name.length >= 4 && withinEdits(word, name, typoBudget(name)))) return '<name>';
if (spelled.length < 4) return spelled;
return VOCABULARY.find(candidate => withinEdits(spelled, candidate, 1)) ?? spelled;
return VOCABULARY.find(candidate => withinEdits(spelled, candidate, 1))
?? VIEW_VOCABULARY.find(candidate => withinEdits(spelled, candidate, candidate.length >= 9 ? 2 : 1)) ?? spelled;
}).join(' ');
}
@@ -76,6 +110,17 @@ export function generalQuestion(prompt: string, names: readonly string[]): 'gene
return detail ? 'detail' : 'general';
}
/** "general" for a short question about the current view ("erkläre die aktuelle Hierarchie",
* "what am I looking at"), "detail" for the same asking for detail, undefined otherwise (H1).
* `names` are the selection's: "die Hierarchie von .github" is the view too. */
export function viewQuestion(prompt: string, names: readonly string[]): 'general' | 'detail' | undefined {
const text = normalize(prompt, names);
const detail = new RegExp(DETAIL.source, 'u').test(text);
const asked = detail ? text.replace(DETAIL, ' ').replace(/\s+/g, ' ').trim() : text;
if (!VIEW_QUESTIONS.some(pattern => pattern.test(asked))) return undefined;
return detail ? 'detail' : 'general';
}
/** Greetings and probes: a prompt of only these asks nothing. */
const GREETINGS = new Set(['hi', 'hey', 'hello', 'hallo', 'moin', 'servus', 'huhu', 'yo', 'test', 'testing', 'tests', 'ok', 'okay', 'danke', 'thanks', 'thank', 'you',
'du', 'ja', 'nein', 'yes', 'no', 'guten', 'morgen', 'tag', 'abend', 'good', 'morning', 'evening', 'there', 'bitte', 'please', 'na', 'hm', 'hmm', 'ping', 'asdf']);
+40
View File
@@ -574,3 +574,43 @@ export const chatRound3Text: { en: typeof englishRound3Text; de: typeof englishR
},
},
};
type ViewDirection = 'both' | 'inbound' | 'outbound';
/** The listed answer to a question about the current Galaxy view (H1), and the note for a model
* answer that only restated its question (H2), in the language of the question. */
const englishViewText = {
center: (name: string, kind: string, roots: number) => `${name} (${kind}) is in the middle${roots > 1 ? ` with ${en(roots)} symbols` : ''}`,
reach: (hops: string, direction: string, both: boolean) => `the scope reaches ${hops}${both ? ' ' : ', '}${direction}`,
selectionOnly: 'the scope shows the selection only',
/** The heading of a side; the hierarchy draws incoming on the left and outgoing on the right. */
side: (side: 'incoming' | 'outgoing', hierarchy: boolean): string => side === 'incoming' ? hierarchy ? 'Left, incoming:' : 'Incoming:' : hierarchy ? 'Right, outgoing:' : 'Outgoing:',
/** How the hierarchy is read, as its hint says it: from two layers on each column is one layer further,
* and nodes reached through both directions stand in the dashed band below. */
readHierarchy: (direction: ViewDirection, columns: boolean) => direction === 'both'
? `How to read the hierarchy: incoming relationships stand on the left, outgoing on the right${columns ? ', each column one layer further in the same direction' : ''}.`
+ (columns ? ' Nodes reached through both directions, such as a callee of a caller, stand in the dashed band "Mixed directions" below.' : '')
: `How to read the hierarchy: the scope follows ${direction === 'inbound' ? 'incoming' : 'outgoing'} relationships only, so everything stands on the ${direction === 'inbound' ? 'left' : 'right'} of the middle${columns ? ', each column one layer further' : ''}.`,
readGalaxy: 'The galaxy view shows this scope as a cloud. "hierarchy" at the top right lays the same scope out in columns: incoming on the left, outgoing on the right.',
size: (size: string, types: string, state: string) => `${size}, ${types}; ${state}.`,
noAnswer: (echoed: string) => `The model gave no answer; it only restated the question ("${echoed}").`,
factsFollow: 'This is what the indexed graph lists for the selection:',
rephrase: 'Ask the question in other words, or ask again.',
};
export const viewText: { en: typeof englishViewText; de: typeof englishViewText } = {
en: englishViewText,
de: {
center: (name: string, kind: string, roots: number) => `${name} (${kind}) steht${roots > 1 ? ` mit ${de(roots)} Symbolen` : ''} in der Mitte`,
reach: (hops: string, direction: string, both: boolean) => `der Ausschnitt geht ${hops}${both ? ' ' : ', '}${direction}`,
selectionOnly: 'der Ausschnitt zeigt nur die Auswahl',
side: (side: 'incoming' | 'outgoing', hierarchy: boolean) => side === 'incoming' ? hierarchy ? 'Links, eingehend:' : 'Eingehend:' : hierarchy ? 'Rechts, ausgehend:' : 'Ausgehend:',
readHierarchy: (direction: ViewDirection, columns: boolean) => direction === 'both'
? `So liest du die Hierarchie: eingehende Beziehungen stehen links, ausgehende rechts${columns ? ', jede Spalte eine Ebene weiter in derselben Richtung' : ''}.`
+ (columns ? ' Was über beide Richtungen erreicht wird, etwa etwas, das ein Aufrufer sonst noch aufruft, steht im gestrichelten Band „Mixed directions“ darunter.' : '')
: `So liest du die Hierarchie: der Ausschnitt folgt nur ${direction === 'inbound' ? 'eingehenden' : 'ausgehenden'} Beziehungen, daher steht alles ${direction === 'inbound' ? 'links' : 'rechts'} der Mitte${columns ? ', jede Spalte eine Ebene weiter' : ''}.`,
readGalaxy: 'Die Galaxy-Ansicht zeigt diesen Ausschnitt als Wolke. „hierarchy“ oben rechts ordnet denselben Ausschnitt in Spalten: eingehend links, ausgehend rechts.',
size: (size: string, types: string, state: string) => `${size}, ${types}; ${state}.`,
noAnswer: (echoed: string) => `Das Modell hat keine Antwort gegeben, es hat nur die Frage wiederholt („${echoed}“).`,
factsFollow: 'Das listet der indizierte Graph zur Auswahl:',
rephrase: 'Stell die Frage mit anderen Worten oder frag erneut.',
},
};
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { generalQuestion, viewQuestion } from './question-intent';
import { readGalaxyEvidence } from './galaxy-evidence';
import { githubFolderEvidence } from './galaxy-evidence.fixture';
import { viewAnswer } from './view-answer';
import type { GraphEdge, GraphNode } from '../galaxy/types';
const names = ['.github', 'django-demo..github'];
/** With .github selected in the hierarchy, "kkannst du mir die heirarchie erklären" and "erkläre die
* aktuelle hierarchy" went to the free model, which only restated them (hand test of 2026-10-04, H1). */
describe('questions about the current view (H1)', () => {
it.each([
'kkannst du mir die heirarchie erklären', 'erkläre die aktuelle hierarchy', 'erkläre die aktuelle ansicht', 'was zeigt mir diese ansicht', 'kannst du mir den graphen erklären',
'Erklär mir den Ausschnitt', 'beschreibe die Struktur', 'was sehe ich hier?', 'Was sehe ich hier eigentlich?', 'Was zeigt der Graph?', 'Wie lese ich diese Hierarchie?',
'erkläre die hierarchie von .github', 'Hierarchie?', 'explain the hierarchy', 'what does this view show', 'Explain this graph', 'What am I looking at?',
'can you explain this view?', 'what is this view?', 'How do I read this hierarchy?', 'describe the structure please', 'explain the current scope', 'erklär mir die heirachie bitte',
])('reads %s as a question about the current view', prompt => {
expect(viewQuestion(prompt, names)).toBe('general');
});
it.each(['erkläre die hierarchie ausführlich', 'explain this view in detail', 'Erklär den Graphen Schritt für Schritt'])('reads %s as one that asks the model for detail', prompt => {
expect(viewQuestion(prompt, names)).toBe('detail');
});
it.each([
'erklär mir das', 'was macht diese klasse?', 'Who calls JSONBAgg?', 'warum ist die hierarchie so aufgebaut?', 'erkläre die Struktur von Aggregate', 'test', 'Hat diese Klasse Luft?',
'explain the hierarchy of Aggregate', 'Wie viele Dateien enthält .github?', 'what does this class do?', 'erkläre die absicht',
])('leaves %s to the other answers', prompt => {
expect(viewQuestion(prompt, names)).toBeUndefined();
});
it('still reads a general question about the selection as one', () => {
expect(generalQuestion('erklär mir das', names)).toBe('general');
expect(generalQuestion('kannst du mir das erklären', names)).toBe('general');
});
});
const evidence = (display?: 'galaxy' | 'hierarchy', overrides?: Parameters<typeof githubFolderEvidence>[1]) => readGalaxyEvidence(githubFolderEvidence(display, overrides).text)!;
describe('the listed answer about the current view (H1)', () => {
it('says what is in the middle, what comes in on the left and what goes out on the right, in German', () => {
const answer = viewAnswer(evidence('hierarchy'), 'de');
expect(answer.split('\n\n')).toEqual([
'`.github` (Ordner) steht in der Mitte; der Ausschnitt geht 1 Schritt in beide Richtungen.',
'Links, eingehend:\n- **CONTAINS_FOLDER (1):** `DETACHED`',
'Rechts, ausgehend:\n- **CONTAINS_FILE (4):** `CODE_OF_CONDUCT.md`, `FUNDING.yml`, `pull_request_template.md`, `SECURITY.md`\n- **CONTAINS_FOLDER (1):** `workflows`',
'So liest du die Hierarchie: eingehende Beziehungen stehen links, ausgehende rechts.',
'7 Symbole und 6 Beziehungen, alle Beziehungstypen; vollständig geladen.',
'_Aus dem indizierten Graphen gelistet, nicht vom Modell erzeugt._',
]);
});
it('says the galaxy view shows the same scope as a cloud, without left and right, in English', () => {
const answer = viewAnswer(evidence('galaxy'), 'en');
expect(answer.split('\n\n')).toEqual([
'`.github` (Folder) is in the middle; the scope reaches 1 hop in both directions.',
'Incoming:\n- **CONTAINS_FOLDER (1):** `DETACHED`',
'Outgoing:\n- **CONTAINS_FILE (4):** `CODE_OF_CONDUCT.md`, `FUNDING.yml`, `pull_request_template.md`, `SECURITY.md`\n- **CONTAINS_FOLDER (1):** `workflows`',
'The galaxy view shows this scope as a cloud. "hierarchy" at the top right lays the same scope out in columns: incoming on the left, outgoing on the right.',
'7 symbols and 6 relationships, all relationship types; fully loaded.',
'_Listed from the indexed graph; not generated by the model._',
]);
});
it('bounds long name lists with "+N more" and names the dashed band from two layers on', () => {
const extra: GraphNode[] = Array.from({ length: 12 }, (_, index) => ({ id: 100 + index, name: `doc_${String(index).padStart(2, '0')}.md`, label: 'File', file_path: `.github/doc_${String(index).padStart(2, '0')}.md`,
x: 0, y: 0, z: 0, size: 1, color: '#999' }));
const edges: GraphEdge[] = extra.map(node => ({ source: 1, target: node.id, type: 'CONTAINS_FILE' }));
const answer = viewAnswer(evidence('hierarchy', { depth: 2, nodes: extra, edges }), 'de');
expect(answer).toContain('- **CONTAINS_FILE (16):** `CODE_OF_CONDUCT.md`, `doc_00.md`, `doc_01.md`, `doc_02.md`, `doc_03.md`, `doc_04.md`, `doc_05.md`, `doc_06.md`; +8 weitere');
expect(answer).toContain('der Ausschnitt geht 2 Schritte in beide Richtungen.');
expect(answer).toContain('So liest du die Hierarchie: eingehende Beziehungen stehen links, ausgehende rechts, jede Spalte eine Ebene weiter in derselben Richtung. '
+ 'Was über beide Richtungen erreicht wird, etwa etwas, das ein Aufrufer sonst noch aufruft, steht im gestrichelten Band „Mixed directions“ darunter.');
});
it('reads a hierarchy that follows one direction only, and names the side that is not loaded', () => {
const answer = viewAnswer(evidence('hierarchy', { direction: 'outbound' }), 'en');
expect(answer).toContain('the scope reaches 1 hop, outgoing only.');
expect(answer).toContain('Incoming: not loaded; the scope does not follow incoming edges.');
expect(answer).toContain('How to read the hierarchy: the scope follows outgoing relationships only, so everything stands on the right of the middle.');
expect(answer).not.toContain('dashed band');
});
it('says nothing about left, right or a cloud when the evidence does not say which view is shown', () => {
const answer = viewAnswer(evidence(), 'en');
expect(answer).toContain('Incoming:\n- **CONTAINS_FOLDER (1):** `DETACHED`');
expect(answer).not.toMatch(/left|right|cloud/);
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { RelationshipGroup } from '../galaxy/selection-evidence';
import { scopeParts, sideLoaded, type GalaxyEvidence } from './galaxy-evidence';
import { relationshipWords, viewText, type RelationshipWords } from './strings';
/** The answer to a question about the current Galaxy view, listed from the loaded scope (H1).
*
* "kkannst du mir die heirarchie erklären" with .github in the hierarchy went to the free
* model, which answered "Ich kann dir die Heirarchy erklären.". The scope holds the answer:
* what is in the middle, what comes in (left in the hierarchy) and what goes out (right),
* each relationship type with its count and names, how far the scope reaches, and how the
* picture is read. Names are bounded per type and the rest counted, as in the listed answers. */
const NAMES_PER_TYPE = 8;
const TYPES_PER_SIDE = 6;
const quote = (value: string) => `\`${value.replace(/`/g, "'")}\``;
/** "- **CONTAINS_FILE (4):** `CODE_OF_CONDUCT.md`, `FUNDING.yml`; +2 more". */
function typeLine(group: RelationshipGroup, words: RelationshipWords): string {
const shown = group.files.flatMap(file => file.symbols.map(symbol => symbol.name)).slice(0, NAMES_PER_TYPE);
const rest = group.count - shown.length;
const names = shown.length ? `${shown.map(quote).join(', ')}${rest > 0 ? `; ${words.more(rest)}` : ''}` : words.more(group.count);
return `- **${words.typeCount(group.type, group.count)}:** ${names}`;
}
const totals = (items: readonly { type: string; count: number }[]) => items.slice(0, TYPES_PER_SIDE).map(item => `${item.type} ${item.count}`).join(', ');
export function viewAnswer(evidence: GalaxyEvidence, language: 'en' | 'de'): string {
const words = relationshipWords[language], text = viewText[language];
const hierarchy = evidence.display === 'hierarchy';
const [first] = evidence.roots;
const single = evidence.rootCount <= 1 && first !== undefined;
const center = text.center(quote(single ? first.name : evidence.label), words.kindName((single ? first.kind : undefined) ?? evidence.selectionKind), single ? 1 : evidence.rootCount);
const { direction, types, state } = scopeParts(evidence, words);
const reach = evidence.depth === 0 ? text.selectionOnly : text.reach(words.hops(evidence.depth), direction, evidence.direction === 'both');
const side = (name: 'incoming' | 'outgoing'): string => {
if (!sideLoaded(evidence, name)) return name === 'incoming' ? words.incomingNotLoaded : words.outgoingNotLoaded;
const groups = evidence.relationships[name];
if (!groups.length) return evidence.truncated ? words.cut(name) : name === 'incoming' ? words.noIncoming : words.noOutgoing;
const lines = groups.slice(0, TYPES_PER_SIDE).map(group => typeLine(group, words));
if (groups.length > TYPES_PER_SIDE) lines.push(`- ${words.moreTypes(groups.length - TYPES_PER_SIDE)}`);
return `${text.side(name, hierarchy)}\n${lines.join('\n')}`;
};
const { internal, beyond } = evidence.relationships;
// The picture is read in the view the reader has; where the evidence does not say which, nothing is said about it.
const reading = evidence.display === 'hierarchy' ? text.readHierarchy(evidence.direction, evidence.depth >= 2) : evidence.display === 'galaxy' ? text.readGalaxy : undefined;
return [
`${center}; ${reach}.`,
...evidence.depth === 0 ? [words.notExpanded] : [side('incoming'), side('outgoing')],
...internal.length ? [words.internal(totals(internal))] : [],
...beyond.length ? [words.beyond(totals(beyond))] : [],
...reading ? [reading] : [],
text.size(words.size(evidence.nodes, evidence.edges), types, state),
...evidence.state === 'loading' ? [words.stillLoading] : [],
`_${words.listedFromGraph}_`,
].join('\n\n');
}
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
/*
* Hand test of 2026-10-04 (H1): the chat answers "erkläre die aktuelle hierarchy" from
* the loaded scope, and to say how to read the picture it has to know which picture
* Galaxy shows. The evidence carries it; switching the picture is no new selection,
* so the explanation of the scope is not written again.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import GalaxyPanel from './GalaxyPanel';
import { scopeFetch, scopeNode } from './test-scope-fetch';
import type { GraphEdge } from './types';
vi.mock('./GraphScene', async importOriginal => ({
...await importOriginal<typeof import('./GraphScene')>(),
GraphScene: () => <output data-testid="scene" />,
}));
let host: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined;
window.localStorage.clear();
});
const seam = () => globalThis.__atlasGalaxy!;
const settle = (check: () => void) => vi.waitFor(async () => {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
check();
});
const status = () => host.querySelector('.atlas-graph-scope-count');
it('H1: tells the chat whether Galaxy shows the cloud or the hierarchy, with the same content identity', async () => {
const nodes = [1, 2, 3].map(id => scopeNode(id));
const edges: GraphEdge[] = [{ id: 1, source: 1, target: 2, type: 'CALLS' }, { id: 2, source: 3, target: 1, type: 'CALLS' }];
const onSelectionEvidence = vi.fn();
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} onSelectionEvidence={onSelectionEvidence}
fetch={scopeFetch({ nodes, edges }).fetch} />));
await settle(() => expect(seam().nodes).toBe(3));
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => expect(status()?.textContent).toBe('3 nodes · 2 edges'));
const last = () => onSelectionEvidence.mock.lastCall![0] as { id: string; text: string };
const display = () => JSON.parse(last().text).evidence.scope.display;
await settle(() => expect(display()).toBe('galaxy'));
const { id } = last();
await act(async () => host.querySelector<HTMLButtonElement>('button.atlas-graph-mode-chip[data-mode="hierarchy"]')!.click());
expect(seam().mode).toBe('hierarchy');
await settle(() => expect(display()).toBe('hierarchy'));
// The same scope in another picture: the chat keeps its explanation.
expect(last().id).toBe(id);
await act(async () => host.querySelector<HTMLButtonElement>('button.atlas-graph-mode-chip[data-mode="galaxy"]')!.click());
await settle(() => expect(display()).toBe('galaxy'));
expect(last().id).toBe(id);
});
+4 -2
View File
@@ -1213,8 +1213,10 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
state: scope.complete ? scope.result?.partial ? 'render-limit-partial' : 'complete-indexed-scope' : scope.loading ? 'loading-partial-preview' : 'partial',
error: scope.error, exhausted: scope.result?.exhausted,
...scope.complete && scope.result?.partial ? { renderLimit: { layer: scope.result.partial.layer, kind: scope.result.partial.limit,
limit: scope.result.partial.limit === 'nodes' ? nodeBudget : edgeBudget } } : {} }) : undefined,
[project, scope.scope, scope.result, scope.depth, scope.direction, scope.complete, scope.loading, scope.error, traceTypes, props.workspaceExpanded, nodeBudget, edgeBudget]);
limit: scope.result.partial.limit === 'nodes' ? nodeBudget : edgeBudget } } : {},
// Which picture is shown, so the chat can say how to read it (H1).
display: mode }) : undefined,
[project, scope.scope, scope.result, scope.depth, scope.direction, scope.complete, scope.loading, scope.error, traceTypes, props.workspaceExpanded, nodeBudget, edgeBudget, mode]);
useSelectionEvidence(props.onSelectionEvidence, agentEvidence, visible && props.workspaceExpanded === true);
const toggleKind = useCallback((type: string) => {
if (props.workspaceExpanded && scope.scope) {
+9 -3
View File
@@ -123,6 +123,8 @@ export interface GalaxyScope {
exhausted?: boolean;
/** The layer that stopped and the limit it stopped at. */
renderLimit?: { layer: number; kind: 'nodes' | 'edges'; limit: number };
/** Which picture of the scope Galaxy shows, so the chat can say how to read it (H1). */
display?: 'galaxy' | 'hierarchy';
}
/** Every scope edge is classified against the roots first; only the names are
@@ -134,7 +136,8 @@ export function galaxyScopeEvidence(scope: GalaxyScope): SelectionEvidence {
return { project: scope.project, view: 'galaxy', source: 'query_graph scoped indexed relationships', label: scopeDisplayName(scope.identity),
selected: { scope: scope.identity, rootCount: roots.length, roots: roots.slice(0, ROOTS_LISTED).map(root => ({ ...graphNodeEvidence(root),
documentation: root.documentation?.slice(0, ROOT_DOCUMENTATION_CHARACTERS) })), omittedRoots: Math.max(0, roots.length - ROOTS_LISTED) },
scope: { depth: scope.depth, direction: scope.direction, edgeTypes: scope.edgeTypes, nodes: scope.nodes.length, edges: scope.edges.length },
scope: { depth: scope.depth, direction: scope.direction, edgeTypes: scope.edgeTypes, nodes: scope.nodes.length, edges: scope.edges.length,
...scope.display ? { display: scope.display } : {} },
relationships: scopeRelationships(scope.nodes, scope.edges, scope.roots),
limitations: { state: scope.state, error: scope.error, exhausted: scope.exhausted, ...scope.renderLimit ? { renderLimit: scope.renderLimit } : {}, indexCoverage: 'unavailable',
interpretation: 'Static indexed relationships, not runtime activity. Scope completeness is relative to the indexed graph and selected depth/types.' } };
@@ -215,9 +218,12 @@ export function selectionEvidenceContext(original: SelectionEvidence): BrowserCh
const snapshot = copy(architecture ? { ...head, relationships: evidence.relationships, selected: evidence.selected }
: { ...head, selected: evidence.selected, relationships: evidence.relationships }, '$', 0);
const text = JSON.stringify({ evidence: snapshot, omissions });
// The picture Galaxy draws a scope in is no code fact: in either one the scope keeps its identity (H1).
const scope = row(row(snapshot)?.scope);
const identity = scope?.display === undefined ? text : JSON.stringify({ evidence: { ...row(snapshot), scope: { ...scope, display: undefined } }, omissions });
let hash = 2166136261;
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
return { id: `selection:${evidence.project}:${evidence.view}:${(hash >>> 0).toString(16)}:${text.length}`,
for (let i = 0; i < identity.length; i++) hash = Math.imul(hash ^ identity.charCodeAt(i), 16777619);
return { id: `selection:${evidence.project}:${evidence.view}:${(hash >>> 0).toString(16)}:${identity.length}`,
label: evidence.label.slice(0, 120), text };
}