mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-28 07:03:20 +08:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } : {} }));
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user