diff --git a/backend/src/api/routes/git/_repo_operations_routes.py b/backend/src/api/routes/git/_repo_operations_routes.py
index d9c4b5359..4859ad1b5 100644
--- a/backend/src/api/routes/git/_repo_operations_routes.py
+++ b/backend/src/api/routes/git/_repo_operations_routes.py
@@ -361,6 +361,8 @@ async def get_commit_diff(
async def generate_commit_message(
dashboard_ref: str,
env_id: str | None = None,
+ purpose: str = "commit",
+ language: str = "Russian",
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
_=Depends(has_permission("plugin:git", "EXECUTE")),
@@ -370,13 +372,15 @@ async def generate_commit_message(
from . import _resolve_dashboard_id_from_ref
try:
+ if purpose not in {"commit", "summary"}:
+ raise HTTPException(status_code=422, detail="purpose must be 'commit' or 'summary'")
dashboard_id = await _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
diff = await _await_service_result(_gs.get_diff(dashboard_id, staged=True))
if not diff or all(item is None for item in diff):
diff = await _await_service_result(_gs.get_diff(dashboard_id, staged=False))
if not diff or all(item is None for item in diff):
- return {"message": "No changes detected"}
+ return {"summary": ""} if purpose == "summary" else {"message": "No changes detected"}
history_objs = await _await_service_result(_gs.get_commit_history(dashboard_id, limit=5))
history = [h.message for h in history_objs if hasattr(h, "message")]
@@ -412,15 +416,23 @@ async def generate_commit_message(
from src.plugins.git.llm_extension import GitLLMExtension
extension = GitLLMExtension(client)
+ if purpose == "summary":
+ summary_prompt = llm_settings["prompts"].get(
+ "git_change_summary_prompt",
+ DEFAULT_LLM_PROMPTS["git_change_summary_prompt"],
+ )
+ summary = await extension.summarize_changes(
+ diff,
+ language=language[:40] or "Russian",
+ prompt_template=summary_prompt,
+ )
+ return {"summary": summary}
+
git_prompt = llm_settings["prompts"].get(
"git_commit_prompt",
DEFAULT_LLM_PROMPTS["git_commit_prompt"],
)
- message = await extension.suggest_commit_message(
- diff,
- history,
- prompt_template=git_prompt,
- )
+ message = await extension.suggest_commit_message(diff, history, prompt_template=git_prompt)
return {"message": message}
except HTTPException:
raise
diff --git a/backend/src/plugins/git/__tests__/test_llm_extension.py b/backend/src/plugins/git/__tests__/test_llm_extension.py
new file mode 100644
index 000000000..8f34c1a72
--- /dev/null
+++ b/backend/src/plugins/git/__tests__/test_llm_extension.py
@@ -0,0 +1,45 @@
+# #region Test.Git.LLMSummary [C:3] [TYPE Module] [SEMANTICS test,git,llm,summary]
+# @BRIEF Verify Git change summaries are authored by the configured LLM from grounded diff context.
+# @RELATION BINDS_TO -> [GitLLMExtension.SummarizeChanges]
+# @TEST_CONTRACT: Superset diff + language -> free-form LLM summary.
+# @TEST_SCENARIO: provider_success -> response text is returned without deterministic reinterpretation.
+# @TEST_EDGE: missing_field -> empty provider content is rejected by the production contract.
+# @TEST_EDGE: invalid_type -> language is rendered as text without changing the diff.
+# @TEST_EDGE: external_fail -> covered by frontend retry-state contract.
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from src.plugins.git.llm_extension import GitLLMExtension
+
+
+# #region Test.Git.LLMSummary.Success [C:2] [TYPE Function] [SEMANTICS test,git,llm]
+# @BRIEF The exact provider prose is returned and the grounding prompt contains diff and requested language.
+@pytest.mark.anyio
+async def test_summarize_changes_returns_provider_prose_and_grounded_prompt():
+ create = AsyncMock(
+ return_value=SimpleNamespace(
+ choices=[SimpleNamespace(message=SimpleNamespace(content="• Изменена логика фильтрации по регионам."))]
+ )
+ )
+ client = SimpleNamespace(
+ default_model="test-model",
+ client=SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))),
+ )
+ extension = GitLLMExtension(client)
+ diff = "diff --git a/dashboards/world.yaml b/dashboards/world.yaml\n-old filter\n+new filter"
+
+ result = await extension.summarize_changes(diff, "Russian")
+
+ assert result == "• Изменена логика фильтрации по регионам."
+ request = create.await_args.kwargs
+ prompt = request["messages"][0]["content"]
+ assert diff in prompt
+ assert "Russian" in prompt
+ assert request["temperature"] == 0.6
+# #endregion Test.Git.LLMSummary.Success
+
+
+# #endregion Test.Git.LLMSummary
diff --git a/backend/src/plugins/git/llm_extension.py b/backend/src/plugins/git/llm_extension.py
index 27d06839a..ac7c0bdbe 100644
--- a/backend/src/plugins/git/llm_extension.py
+++ b/backend/src/plugins/git/llm_extension.py
@@ -61,6 +61,54 @@ class GitLLMExtension:
return response.choices[0].message.content.strip()
# endregion suggest_commit_message
+
+ # #region GitLLMExtension.SummarizeChanges [C:4] [TYPE Function] [SEMANTICS git,llm,summary,bi]
+ # @ingroup Git
+ # @BRIEF Generate a grounded, BI-facing explanation of a Superset workspace diff.
+ # @PRE diff contains the current repository changes; language is a human-readable language name.
+ # @POST Returns non-empty free-form LLM prose without applying deterministic YAML interpretation.
+ # @SIDE_EFFECT Calls the configured external LLM provider and may take multiple minutes including retry.
+ # @RATIONALE The meaning of Superset YAML changes is context-sensitive; fixed key-to-label mappings create
+ # false certainty. The LLM receives the full diff with a grounding prompt and the UI exposes
+ # loading/error/retry states instead of substituting an algorithmic fallback.
+ # @REJECTED Parsing selected YAML keys into business statements was rejected because it silently omits
+ # unknown chart, dataset, filter, and plugin-specific changes.
+ @retry(
+ stop=stop_after_attempt(2),
+ wait=wait_exponential(multiplier=1, min=2, max=10),
+ reraise=True,
+ )
+ async def summarize_changes(
+ self,
+ diff: str,
+ language: str,
+ prompt_template: str = DEFAULT_LLM_PROMPTS["git_change_summary_prompt"],
+ ) -> str:
+ with belief_scope("GitLLMExtension.summarize_changes"):
+ prompt = render_prompt(
+ prompt_template,
+ {"diff": diff, "language": language},
+ )
+ logger.reason(
+ "Requesting BI-facing Git change summary",
+ payload={"model": self.client.default_model, "diff_chars": len(diff), "language": language},
+ )
+ response = await self.client.client.chat.completions.create(
+ model=self.client.default_model,
+ messages=[{"role": "user", "content": prompt}],
+ temperature=0.6,
+ )
+ if not response or not getattr(response, "choices", None):
+ raise RuntimeError("LLM returned no change summary")
+ summary = str(response.choices[0].message.content or "").strip()
+ if not summary:
+ raise RuntimeError("LLM returned an empty change summary")
+ logger.reflect(
+ "BI-facing Git change summary generated",
+ payload={"summary_chars": len(summary)},
+ )
+ return summary
+ # #endregion GitLLMExtension.SummarizeChanges
# #endregion GitLLMExtension
# #endregion GitLLMExtensionModule
diff --git a/backend/src/services/llm_prompt_templates.py b/backend/src/services/llm_prompt_templates.py
index b10b87fc2..688fc56ac 100644
--- a/backend/src/services/llm_prompt_templates.py
+++ b/backend/src/services/llm_prompt_templates.py
@@ -104,6 +104,19 @@ DEFAULT_LLM_PROMPTS: dict[str, str] = {
"{diff}\n\n"
"Commit Message:"
),
+ "git_change_summary_prompt": (
+ "You explain Apache Superset dashboard changes to a BI analyst.\n"
+ "Based only on the Git diff below, describe what meaningfully changed in the dashboard.\n"
+ "Focus on dashboards, charts, datasets, filters, database permissions, and user-visible behavior.\n"
+ "Ignore metadata.yaml timestamps, fingerprints, file names, YAML syntax, Git mechanics, and formatting-only noise.\n"
+ "Do not invent business impact that is not supported by the diff.\n"
+ "Write in {language}. Return 1-4 short Markdown bullet points without a heading.\n"
+ "You may use bold and inline code. Do not use links, tables, HTML, or fenced code blocks.\n"
+ "If the diff contains no meaningful BI change, say that explicitly in one sentence.\n\n"
+ "Diff:\n"
+ "{diff}\n\n"
+ "Summary:"
+ ),
}
# #endregion DEFAULT_LLM_PROMPTS
diff --git a/frontend/e2e/tests/git.e2e.js b/frontend/e2e/tests/git.e2e.js
index 074a34ab9..6f605f25c 100644
--- a/frontend/e2e/tests/git.e2e.js
+++ b/frontend/e2e/tests/git.e2e.js
@@ -90,16 +90,44 @@ test.describe('Git Integration', () => {
test.describe('Git dashboard recovery browser states', () => {
test('release screen separates a validated publication from unsaved dashboard edits', async ({ authPage }) => {
+ await authPage.route('**/api/git/repositories/*/generate-message?*', async (route) => {
+ const requestUrl = new URL(route.request().url());
+ if (requestUrl.searchParams.get('purpose') !== 'summary') {
+ await route.continue();
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ summary: '- Изменено **название** дашборда.\n- Разрешён параметр `allow_dml`.',
+ }),
+ });
+ });
await authPage.goto(`${FRONTEND_URL}/git`);
const dashboardRow = authPage.locator('tr').filter({ hasText: "World Bank's Data" }).first();
await expect(dashboardRow).toBeVisible();
await dashboardRow.getByRole('button', { name: /Управление Git|Manage Git/ }).click();
const dialog = authPage.getByRole('dialog', { name: /Управление Git|Git management/ });
- await expect(dialog.getByText(/Проверенная версия для пользователей|Validated version for users/)).toBeVisible();
+ await expect(dialog.getByText(/Кандидат на публикацию|Publication candidate/)).toBeVisible();
await expect(dialog.getByRole('button', { name: /Опубликовать проверенную версию|Publish validated version/ })).toBeVisible();
await expect(dialog.getByText(/Есть несохранённые изменения|There are unsaved changes/)).toBeVisible();
await expect(dialog.getByText(/в публикацию не войдут|will not be included in the current publication/)).toBeVisible();
+ await expect(dialog).not.toContainText('.superset-tools-fingerprint');
+ await expect(dialog).not.toContainText('metadata.yaml');
+
+ await dialog.getByRole('button', { name: /Перейти к сохранению|Go to saving/ }).click();
+ await expect(dialog.locator('#git-commit-message')).toBeFocused();
+ await expect(dialog.getByRole('button', { name: /^Сохранить версию$|^Save version$/ })).toBeVisible();
+ await expect(dialog.getByText(/Ключевые изменения|Key changes/)).toBeVisible();
+ const renderedSummary = dialog.getByTestId('workspace-summary-markdown');
+ await expect(renderedSummary.locator('li')).toHaveCount(2);
+ await expect(renderedSummary.locator('strong')).toHaveText('название');
+ await expect(renderedSummary.locator('code')).toHaveText('allow_dml');
+ await expect(renderedSummary).not.toContainText('**название**');
+ const technicalDiff = dialog.getByText(/Технический YAML diff|Technical YAML diff/).locator('..');
+ await expect(technicalDiff).not.toHaveAttribute('open', '');
});
test('checkout conflict state exposes scoped recovery actions', async ({ page }) => {
diff --git a/frontend/src/lib/components/assistant/MarkdownRenderer.svelte b/frontend/src/lib/components/assistant/MarkdownRenderer.svelte
index 2cf5510f6..d0cab64a5 100644
--- a/frontend/src/lib/components/assistant/MarkdownRenderer.svelte
+++ b/frontend/src/lib/components/assistant/MarkdownRenderer.svelte
@@ -5,15 +5,25 @@
+
{#if source}
-
+ {#if blockHtml}
+
+ {:else}
+
+ {/if}
{/if}
diff --git a/frontend/src/lib/components/git/GitDeploymentPipeline.svelte b/frontend/src/lib/components/git/GitDeploymentPipeline.svelte
index fb402f194..adc41c1bf 100644
--- a/frontend/src/lib/components/git/GitDeploymentPipeline.svelte
+++ b/frontend/src/lib/components/git/GitDeploymentPipeline.svelte
@@ -26,12 +26,14 @@
drift_status?: 'in_sync' | 'drifted' | 'unknown' | null;
};
type DeploymentStatus = { environments: EnvironmentStatus[]; current_content_hash: string | null };
+ type CandidateCommit = { hash: string; message?: string; author?: string; timestamp?: string | Date };
type PipelineAction = 'sync' | 'deploy_preprod' | 'validate_preprod' | 'deploy_prod' | 'current';
let {
deploymentStatus = null as DeploymentStatus | null,
currentVersionDate = null as string | null,
currentVersionHash = null as string | null,
+ candidateCommit = null as CandidateCommit | null,
hasWorkspaceChanges = false,
changedFilesCount = 0,
validatingPreprod = false,
@@ -51,6 +53,9 @@
// A shared PREPROD tests one immutable release candidate. DEV may advance while it is tested.
let preprodValidated = $derived(Boolean(preprod?.commit_hash && preprod?.validation_status === 'validated'));
let prodMatchesPreprod = $derived(Boolean(preprod?.content_hash && prod?.content_hash === preprod.content_hash));
+ let prodHasSameGitVersionButDifferentContent = $derived(Boolean(
+ prod?.commit_hash && currentVersionHash && prod.commit_hash === currentVersionHash && !prodMatchesDev
+ ));
let action = $derived.by((): PipelineAction => {
if (!currentHash) return 'sync';
if (preprod?.commit_hash && preprod.validation_status === 'validated' && !prodMatchesPreprod) return 'deploy_prod';
@@ -73,9 +78,12 @@
if (action === 'deploy_prod' && preprod?.commit_hash) {
const version = preprod.commit_hash.slice(0, 12);
const base = (git.pipeline_message_deploy_prod || 'В PROD будет опубликована проверенная версия {version}.').replace('{version}', version);
- return hasWorkspaceChanges
- ? `${base} ${(git.pipeline_workspace_excluded || 'Текущие несохранённые изменения ({count} файлов) в публикацию не войдут.').replace('{count}', String(changedFilesCount))}`
+ const publication = prodHasSameGitVersionButDifferentContent
+ ? `${base} ${git.pipeline_message_resolve_drift || 'Публикация перезапишет отличающееся содержимое PROD и устранит drift.'}`
: base;
+ return hasWorkspaceChanges
+ ? `${publication} ${(git.pipeline_workspace_excluded || 'Текущие несохранённые изменения ({count} файлов) в публикацию не войдут.').replace('{count}', String(changedFilesCount))}`
+ : publication;
}
return action === 'deploy_preprod' && preprod?.commit_hash
? (git.pipeline_candidate_replaced || 'Новый кандидат заменит текущий кандидат PREPROD и потребует повторной проверки.')
@@ -98,7 +106,8 @@
}
if (!prod?.content_hash) return git.pipeline_not_deployed || 'Версия не развёрнута';
if (prod.drift_status === 'drifted') return 'Обнаружены ручные изменения';
- return prodMatchesDev ? git.pipeline_live || 'Пользователи видят эту версию' : git.pipeline_outdated || 'Не соответствует DEV';
+ if (prodHasSameGitVersionButDifferentContent) return git.pipeline_content_drift || 'Содержимое PROD отличается';
+ return prodMatchesDev ? git.pipeline_live || 'Пользователи видят эту версию' : git.pipeline_outdated || 'Пользователи видят другую версию';
}
function stageHash(stage: string): string | null {
if (stage === 'dev') return currentHash;
@@ -135,7 +144,7 @@
{$t.git?.pipeline_title || 'Путь публикации дашборда'}
{$t.git?.pipeline_hint || 'Версия проходит разработку, проверку и публикацию по содержимому дашборда.'}
- {#if action !== 'current' && currentHash && prod?.content_hash}
+ {#if action !== 'current' && currentHash && prod?.content_hash && !prodHasSameGitVersionButDifferentContent}
{$t.git?.pipeline_compare || 'Сравнить с PROD'}
@@ -146,10 +155,18 @@
{#if preprod?.commit_hash}
-
{$t.git?.pipeline_preprod_candidate || 'Проверенная версия для пользователей'}:
-
{preprod.commit_hash.slice(0, 12)}
-
· {preprod.validation_status === 'validated' ? 'проверен' : 'ожидает проверки'}
- {#if !preprodMatchesDev}
· {$t.git?.pipeline_dev_ahead || 'В DEV есть более новые изменения'} {/if}
+
+ {$t.git?.pipeline_preprod_candidate || 'Кандидат на публикацию'}:
+ {#if candidateCommit?.message}{candidateCommit.message} {/if}
+ {preprod.commit_hash.slice(0, 12)}
+ · {preprod.validation_status === 'validated' ? ($t.git?.pipeline_candidate_validated || 'проверен') : ($t.git?.pipeline_candidate_pending || 'ожидает проверки')}
+
+
+ {#if candidateCommit?.author}{$t.git?.pipeline_candidate_author || 'Автор'}: {candidateCommit.author} {/if}
+ {#if candidateCommit?.timestamp}{$t.git?.pipeline_candidate_created || 'Создан'}: {formatDate(String(candidateCommit.timestamp))} {/if}
+ {#if preprod.source_branch}{$t.git?.candidate_source || 'Источник'}: {preprod.source_branch} {/if}
+ {#if !preprodMatchesDev}· {$t.git?.pipeline_dev_ahead || 'В DEV есть более новые изменения'} {/if}
+
{/if}
@@ -165,7 +182,9 @@
{$t.git?.pipeline_version || 'Версия'} {stageVersion(stage)}
{stage === 'dev' ? ($t.git?.pipeline_changed || 'Обновлена') : ($t.git?.pipeline_deployed || 'Развёрнута')} {formatDate(stageDate(stage))}
{#if stage !== 'dev' && (stage === 'preprod' ? preprod?.drift_status : prod?.drift_status) === 'drifted'}
-
Требуется синхронизация версии
+
{$t.git?.pipeline_sync_required || 'Требуется синхронизация версии'}
+ {:else if stage === 'prod' && prodHasSameGitVersionButDifferentContent}
+
{$t.git?.pipeline_same_revision_different_content || 'Git-версия та же, но содержимое окружения отличается'}
{/if}
diff --git a/frontend/src/lib/components/git/GitFeatureWorkflow.svelte b/frontend/src/lib/components/git/GitFeatureWorkflow.svelte
index a4c5e8afc..7230420d1 100644
--- a/frontend/src/lib/components/git/GitFeatureWorkflow.svelte
+++ b/frontend/src/lib/components/git/GitFeatureWorkflow.svelte
@@ -75,11 +75,15 @@
}
-
+ 0 ? 'p-4' : 'px-4 py-3'}`} aria-label={$t.git?.feature_flow_title || 'Черновики доработок'}>
{$t.git?.feature_flow_title || 'Черновики доработок'}
-
{$t.git?.feature_flow_hint || 'Передайте готовую доработку в общую разработку DEV. Это ещё не отправляет её на проверку или пользователям.'}
+
+ {workingFeatureBranches.length > 0
+ ? ($t.git?.feature_flow_hint || 'Передайте готовую доработку в общую разработку DEV. Это ещё не отправляет её на проверку или пользователям.')
+ : ($t.git?.feature_flow_no_active || 'Нет отдельных доработок, ожидающих передачи в DEV.')}
+
{featureBranches.length}
@@ -87,13 +91,7 @@
- {#if workingFeatureBranches.length === 0}
-
- {archivedFeatureBranches.length > 0
- ? ($t.git?.feature_flow_no_active || 'Нет активных доработок: все черновики уже в DEV.')
- : ($t.git?.feature_flow_empty || 'Нет отдельных черновиков. Создайте feature-ветку в технических деталях.')}
-
- {:else}
+ {#if workingFeatureBranches.length > 0}
{#each workingFeatureBranches as branch (branch.name)}
diff --git a/frontend/src/lib/components/git/GitManager.svelte b/frontend/src/lib/components/git/GitManager.svelte
index e1834b222..001cb917f 100644
--- a/frontend/src/lib/components/git/GitManager.svelte
+++ b/frontend/src/lib/components/git/GitManager.svelte
@@ -106,6 +106,15 @@
function handleBackdropClick(e) { if (e.target === e.currentTarget) closeModal(); }
+ function navigateToVersionSave(): void {
+ model.activeTab = 'workspace';
+ requestAnimationFrame(() => {
+ const target = document.getElementById('git-workspace-save');
+ target?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ requestAnimationFrame(() => target?.querySelector
('#git-commit-message')?.focus());
+ });
+ }
+
onMount(() => {
model.initialize();
lastFocused = document.activeElement as HTMLElement | null;
@@ -230,6 +239,7 @@
deploymentStatus={model.deploymentStatus}
currentVersionDate={model.workspaceStatus?.last_commit_date || null}
currentVersionHash={model.workspaceStatus?.last_commit_hash || null}
+ candidateCommit={model.environmentHistories.preprod?.find((commit) => commit.hash === model.deploymentStatus?.environments.find((environment) => environment.stage === 'preprod')?.commit_hash) || null}
hasWorkspaceChanges={model.hasWorkspaceChanges}
changedFilesCount={model.changedFilesCount}
validatingPreprod={model.validatingPreprod}
@@ -258,8 +268,8 @@
{$t.git?.workspace_pending_title || 'Есть несохранённые изменения'}
{($t.git?.workspace_pending_hint || '{count} файлов изменены. Они не войдут в текущую публикацию, пока вы не сохраните новую версию.').replace('{count}', String(model.changedFilesCount))}
-
{ model.activeTab = 'workspace'; }}>
- {$t.git?.workspace_pending_action || 'Сохранить новую версию'}
+
+ {$t.git?.workspace_pending_action || 'Перейти к сохранению'}
{/if}
@@ -342,7 +352,7 @@
{#if model.activeTab === 'workspace'}
-
model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onCommit={() => model.handleCommit()} />
+ model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onGenerateSummary={() => model.handleGenerateWorkspaceSummary(true)} onCommit={() => model.handleCommit()} />
{:else if model.activeTab === 'release'}
model.handlePromote()} onDeploy={() => model.openDeployModal()} onOpenHistory={() => (model.activeTab = 'workspace')} />
{:else}
diff --git a/frontend/src/lib/components/git/GitWorkspacePanel.svelte b/frontend/src/lib/components/git/GitWorkspacePanel.svelte
index 6bd4f44da..d6829f3c0 100644
--- a/frontend/src/lib/components/git/GitWorkspacePanel.svelte
+++ b/frontend/src/lib/components/git/GitWorkspacePanel.svelte
@@ -7,9 +7,12 @@
+
+
+
-
-
+
+
+
-
-
-
+
+
+
{$t.git?.workspace_save_title || 'Сохранить версию'}
+
{$t.git?.workspace_save_hint || 'Опишите смысл изменений — это описание увидят при проверке и публикации.'}
+
+
{$t.git?.workspace_version_description || 'Описание версии'}
+
+ {#if hasWorkspaceChanges && !commitMessage.trim()}
+
{$t.git?.workspace_description_required || 'Добавьте описание версии, чтобы сохранить её.'}
+ {/if}
+
{$t.git?.files_with_changes || 'Файлов с изменениями:'}
{changedFilesCount}
@@ -236,23 +252,16 @@
size="lg"
>
- {$t.git?.commit_button || 'Создать коммит'}
+ {$t.git?.workspace_save_button || 'Сохранить версию'}
-
-
- {$t.git?.auto_push_after_commit || 'Сделать push после commit в'} {pushProviderLabel}
-
-
-
-
-
-
{$t.git?.commit_message || 'Сообщение коммита'}
-
+
+ {$t.git?.workspace_save_options || 'Параметры сохранения'}
+
+
+ {$t.git?.auto_push_after_commit || 'Сделать push после commit в'} {pushProviderLabel}
+
+
@@ -276,7 +285,7 @@
class="flex-1"
>
- {$t.git?.sync_compact || '🔄 Sync'}
+ {$t.git?.workspace_refresh_changes || 'Обновить изменения'}
@@ -286,48 +295,15 @@
-
{$t.git?.diff_title || 'Diff (изменения)'}
+
{$t.git?.workspace_changes_title || 'Изменения версии'}
{#if hasWorkspaceChanges}
{($t.git?.files_count || '{count} файлов').replace('{count}', String(changedFilesCount))}
- {#if totalChunks > 0}
- · {renderedChunkCount}/{totalChunks}
- {/if}
{/if}
{/if}
- {#if hasWorkspaceChanges && workspaceDiff}
-
-
-
- {$t.git?.advanced_raw_diff_title || $t.git?.raw_diff_title || 'Advanced: raw YAML diff'}
-
-
-
-
{rawDiffLines.join('\n')}
- {#if rawDiffLines.length >= 600}
-
{$t.git?.raw_diff_limited || 'Showing first 600 matching lines.'}
+ {#if hasWorkspaceChanges}
+
+
+
{$t.git?.semantic_key_changes || 'Ключевые изменения'}
+ {#if workspaceSummaryState === 'ready'}
+
+
+ {$t.git?.workspace_summary_regenerate || 'Сформулировать иначе'}
+
{/if}
-
+
+ {#if workspaceSummaryState === 'loading'}
+
+
+
+
{$t.git?.workspace_summary_loading || 'Агент анализирует изменения…'}
+
{$t.git?.workspace_summary_loading_hint || 'Это может занять несколько минут. Можно продолжать работу с версией.'}
+
+
+ {:else if workspaceSummaryState === 'ready' && workspaceSummary}
+
+
+
+ {$t.git?.workspace_summary_ai_notice || 'Описание сформировано AI по текущему diff.'}
+ {:else if workspaceSummaryState === 'error'}
+
+
+
{$t.git?.workspace_summary_failed || 'Не удалось описать изменения'}
+
{workspaceSummaryError || $t.git?.workspace_summary_failed_hint || 'Проверьте настройку LLM и повторите запрос.'}
+
+
+
+ {$t.git?.workspace_summary_retry || 'Повторить'}
+
+
+ {:else}
+
+ {$t.git?.workspace_summary_generate || 'Описать изменения с помощью AI'}
+
+ {/if}
+
{/if}
{#if workspaceLoading}
@@ -371,17 +375,68 @@
{:else if hasWorkspaceChanges && renderedHtml}
-
-
- {@html renderedHtml}
-
- {$t.git?.diff_rendered_hidden_hint || 'Visual diff is hidden from screen reader — use the raw diff below.'}
-
+
+
+
+ {$t.git?.technical_yaml_preview || 'Технический YAML diff'}
+
+
+
+
+ {$t.git?.open_raw_diff || 'Открыть текстовый diff'}
+
+
+ {$t.git?.diff_view_mode || 'Режим'}:
+ (diffViewMode = 'line-by-line')}
+ aria-pressed={diffViewMode === 'line-by-line'}
+ >
+ {$t.git?.diff_view_line_by_line || 'Одна колонка'}
+
+ (diffViewMode = 'side-by-side')}
+ aria-pressed={diffViewMode === 'side-by-side'}
+ >
+ {$t.git?.diff_view_side_by_side || 'Две колонки'}
+
+
+
+
+
+
+ {$t.git?.raw_diff_title || 'Текстовый YAML diff'}
+
+
+
+
{rawDiffLines.join('\n')}
+ {#if rawDiffLines.length >= 600}
+
{$t.git?.raw_diff_limited || 'Показаны первые 600 подходящих строк.'}
+ {/if}
+
+
+
+
+ {@html renderedHtml}
+
+
{$t.git?.diff_rendered_hidden_hint || 'Visual diff is hidden from screen reader — use the raw diff below.'}
+
{#if hasMoreChunks}
{$t.git?.diff_loading || 'Загрузка изменений...'}
{:else}
-
{
+ {
renderedChunkCount = Math.min(renderedChunkCount + CHUNKS_PER_PAGE, totalChunks);
}}>
{$t.git?.diff_show_more?.replace('{count}', totalChunks - renderedChunkCount) || `Показать ещё (${totalChunks - renderedChunkCount} файлов)`}
-
+
{/if}
{:else if totalChunks > 0}
@@ -407,6 +462,8 @@
{$t.git?.diff_all_shown?.replace('{count}', totalChunks) || `Показаны все изменения (${totalChunks} файлов)`}
{/if}
+
+
{:else if hasWorkspaceChanges}
diff --git a/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts b/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts
index b70d0b600..86760d7de 100644
--- a/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts
+++ b/frontend/src/lib/components/git/__tests__/GitDeploymentPipeline.test.ts
@@ -74,5 +74,38 @@ describe('GitDeploymentPipeline', () => {
expect(screen.getByRole('button', { name: 'Создать новый кандидат PREPROD' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Опубликовать в PROD' })).toBeNull();
});
+
+ it('shows a human-readable candidate description in addition to its technical id', () => {
+ render(GitDeploymentPipeline, {
+ deploymentStatus: validatedCandidate,
+ candidateCommit: {
+ hash: 'candidate-123456789',
+ message: 'Добавлен фильтр по региону',
+ author: 'BI Analyst',
+ timestamp: '2026-07-13T06:00:00Z',
+ },
+ });
+
+ expect(screen.getByText('Добавлен фильтр по региону')).toBeTruthy();
+ expect(screen.getByText(/Автор: BI Analyst/)).toBeTruthy();
+ });
+
+ it('explains content drift when PROD has the same Git commit as DEV', () => {
+ render(GitDeploymentPipeline, {
+ currentVersionHash: 'same-commit',
+ deploymentStatus: {
+ current_content_hash: 'dev-content',
+ environments: [
+ { stage: 'preprod', commit_hash: 'same-commit', content_hash: 'dev-content', deployed_at: null, validation_status: 'validated' },
+ { stage: 'prod', commit_hash: 'same-commit', content_hash: 'prod-drift', deployed_at: null, validation_status: 'validated' },
+ ],
+ },
+ });
+
+ expect(screen.getByText('Содержимое PROD отличается')).toBeTruthy();
+ expect(screen.getByText('Git-версия та же, но содержимое окружения отличается')).toBeTruthy();
+ expect(screen.getByText(/Публикация перезапишет отличающееся содержимое PROD/)).toBeTruthy();
+ expect(screen.queryByRole('button', { name: 'Сравнить с PROD' })).toBeNull();
+ });
});
// #endregion Test.Git.DeploymentPipeline
diff --git a/frontend/src/lib/components/git/__tests__/GitWorkspacePanel.test.ts b/frontend/src/lib/components/git/__tests__/GitWorkspacePanel.test.ts
new file mode 100644
index 000000000..a4090dde1
--- /dev/null
+++ b/frontend/src/lib/components/git/__tests__/GitWorkspacePanel.test.ts
@@ -0,0 +1,117 @@
+// #region Test.Git.WorkspacePanel [C:3] [TYPE Module] [SEMANTICS test,git,workspace,bi]
+// @BRIEF Verify asynchronous LLM meaning precedes technical YAML and saving explains its prerequisite.
+// @RELATION BINDS_TO -> [GitWorkspacePanel]
+// @TEST_CONTRACT: Workspace status + LLM state -> grounded summary UX + progressive technical disclosure.
+// @TEST_SCENARIO: summary_ready -> free-form agent Markdown is rendered as semantic content.
+// @TEST_EDGE: missing_description -> save is disabled and recovery guidance is visible.
+// @TEST_EDGE: long_request -> progress is announced while save remains available.
+// @TEST_EDGE: external_fail -> inline retry is available without replacing content algorithmically.
+import { fireEvent, render, screen } from '@testing-library/svelte';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import GitWorkspacePanel from '../GitWorkspacePanel.svelte';
+
+vi.mock('$lib/i18n/index.svelte.js', () => ({
+ t: {
+ subscribe(run: (value: { git: Record
}) => void) {
+ run({ git: {} });
+ return () => {};
+ },
+ },
+}));
+
+beforeAll(() => {
+ vi.stubGlobal('IntersectionObserver', class {
+ observe() {}
+ disconnect() {}
+ });
+});
+
+const semanticDiff = [
+ 'diff --git a/dashboards/world.yaml b/dashboards/world.yaml',
+ '--- a/dashboards/world.yaml',
+ '+++ b/dashboards/world.yaml',
+ '@@ -1 +1 @@',
+ '- dashboard_title: World Bank [draft]',
+ '+ dashboard_title: World Bank',
+ 'diff --git a/databases/examples.yaml b/databases/examples.yaml',
+ '--- a/databases/examples.yaml',
+ '+++ b/databases/examples.yaml',
+ '@@ -1 +1 @@',
+ '- allow_dml: false',
+ '+ allow_dml: true',
+].join('\n');
+
+describe('GitWorkspacePanel BI projection', () => {
+ it('shows the free-form agent summary before a collapsed technical diff', () => {
+ const { container } = render(GitWorkspacePanel, {
+ hasWorkspaceChanges: true,
+ changedFilesCount: 2,
+ workspaceStatus: { modified_files: ['dashboards/world.yaml', 'databases/examples.yaml'] },
+ workspaceLoading: false,
+ workspaceDiff: semanticDiff,
+ workspaceSummary: '- Изменено **название** дашборда.\n- Разрешён параметр `allow_dml`.\n ',
+ workspaceSummaryState: 'ready',
+ committing: false,
+ generatingMessage: false,
+ commitMessage: '',
+ autoPushAfterCommit: true,
+ loading: false,
+ pushProviderLabel: 'gitea',
+ });
+
+ const summary = screen.getByTestId('workspace-summary-markdown');
+ expect(summary.querySelectorAll('li')).toHaveLength(2);
+ expect(summary.querySelector('strong')?.textContent).toBe('название');
+ expect(summary.querySelector('code')?.textContent).toBe('allow_dml');
+ expect(summary.querySelector('img')).toBeNull();
+ const technical = screen.getByText('Технический YAML diff').closest('details');
+ expect(technical?.hasAttribute('open')).toBe(false);
+ expect(container.textContent).toContain('Добавьте описание версии, чтобы сохранить её.');
+ expect(screen.getByRole('button', { name: 'Сохранить версию' }).hasAttribute('disabled')).toBe(true);
+ });
+
+ it('keeps saving available during a long-running summary request', () => {
+ render(GitWorkspacePanel, {
+ hasWorkspaceChanges: true,
+ changedFilesCount: 1,
+ workspaceStatus: { modified_files: ['dashboards/world.yaml'] },
+ workspaceLoading: false,
+ workspaceDiff: semanticDiff,
+ workspaceSummaryState: 'loading',
+ committing: false,
+ generatingMessage: false,
+ commitMessage: 'Обновлён дашборд',
+ autoPushAfterCommit: true,
+ loading: false,
+ pushProviderLabel: 'gitea',
+ });
+
+ expect(screen.getByText('Агент анализирует изменения…')).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Сохранить версию' }).hasAttribute('disabled')).toBe(false);
+ });
+
+ it('offers an inline retry when the LLM request fails', async () => {
+ const onGenerateSummary = vi.fn();
+ render(GitWorkspacePanel, {
+ hasWorkspaceChanges: true,
+ changedFilesCount: 1,
+ workspaceStatus: { modified_files: ['dashboards/world.yaml'] },
+ workspaceLoading: false,
+ workspaceDiff: semanticDiff,
+ workspaceSummaryState: 'error',
+ workspaceSummaryError: 'Provider timeout',
+ committing: false,
+ generatingMessage: false,
+ commitMessage: '',
+ autoPushAfterCommit: true,
+ loading: false,
+ pushProviderLabel: 'gitea',
+ onGenerateSummary,
+ });
+
+ expect(screen.getByText('Provider timeout')).toBeTruthy();
+ await fireEvent.click(screen.getByRole('button', { name: 'Повторить' }));
+ expect(onGenerateSummary).toHaveBeenCalledOnce();
+ });
+});
+// #endregion Test.Git.WorkspacePanel
diff --git a/frontend/src/lib/i18n/locales/en/git.json b/frontend/src/lib/i18n/locales/en/git.json
index 033582315..26704b096 100644
--- a/frontend/src/lib/i18n/locales/en/git.json
+++ b/frontend/src/lib/i18n/locales/en/git.json
@@ -441,7 +441,11 @@
"pipeline_no_version": "Version has not been synchronized",
"pipeline_dev_ready": "Version is ready for testing",
"pipeline_not_deployed": "Version is not deployed",
- "pipeline_outdated": "Does not match DEV",
+ "pipeline_outdated": "Users see a different version",
+ "pipeline_content_drift": "PROD content is different",
+ "pipeline_same_revision_different_content": "The Git version is the same, but the environment content differs",
+ "pipeline_sync_required": "Version synchronization is required",
+ "pipeline_message_resolve_drift": "Publishing overwrites the different PROD content and resolves the drift.",
"pipeline_validation_needed": "Validation is required",
"pipeline_validated": "Validation passed",
"pipeline_live": "Users see this version",
@@ -504,10 +508,33 @@
"feature_flow_transfer_confirm": "Changes from this draft will become available in DEV. They will not reach PREPROD or PROD until you start publication.",
"feature_flow_transferred": "transferred to DEV",
"feature_flow_transfer_failed": "Could not transfer the change to DEV",
- "pipeline_preprod_candidate": "Validated version for users",
+ "pipeline_preprod_candidate": "Publication candidate",
+ "pipeline_candidate_validated": "validated",
+ "pipeline_candidate_pending": "awaiting validation",
+ "pipeline_candidate_author": "Author",
+ "pipeline_candidate_created": "Created",
"pipeline_dev_ahead": "DEV has newer changes",
"pipeline_next_action": "Next action",
"workspace_pending_title": "There are unsaved changes",
"workspace_pending_hint": "{count} files changed. They will not be included in the current publication until you save a new version.",
- "workspace_pending_action": "Save new version"
+ "workspace_pending_action": "Go to saving",
+ "workspace_save_title": "Save version",
+ "workspace_save_hint": "Describe the meaning of the changes — this description is shown during validation and publication.",
+ "workspace_version_description": "Version description",
+ "workspace_save_button": "Save version",
+ "workspace_save_options": "Save options",
+ "workspace_refresh_changes": "Refresh changes",
+ "workspace_description_required": "Add a version description to save it.",
+ "workspace_changes_title": "Version changes",
+ "semantic_key_changes": "Key changes",
+ "workspace_summary_loading": "The agent is analyzing the changes…",
+ "workspace_summary_loading_hint": "This may take a few minutes. You can keep working with the version.",
+ "workspace_summary_ai_notice": "AI generated this explanation from the current diff.",
+ "workspace_summary_regenerate": "Phrase differently",
+ "workspace_summary_failed": "Could not describe the changes",
+ "workspace_summary_failed_hint": "Check the LLM configuration and retry the request.",
+ "workspace_summary_retry": "Retry",
+ "workspace_summary_generate": "Describe changes with AI",
+ "technical_yaml_preview": "Technical YAML diff",
+ "open_raw_diff": "Open text diff"
}
diff --git a/frontend/src/lib/i18n/locales/ru/git.json b/frontend/src/lib/i18n/locales/ru/git.json
index a9922e5f7..321a0bac6 100644
--- a/frontend/src/lib/i18n/locales/ru/git.json
+++ b/frontend/src/lib/i18n/locales/ru/git.json
@@ -441,7 +441,11 @@
"pipeline_no_version": "Версия не синхронизирована",
"pipeline_dev_ready": "Версия готова к тестированию",
"pipeline_not_deployed": "Версия не развёрнута",
- "pipeline_outdated": "Не соответствует DEV",
+ "pipeline_outdated": "Пользователи видят другую версию",
+ "pipeline_content_drift": "Содержимое PROD отличается",
+ "pipeline_same_revision_different_content": "Git-версия та же, но содержимое окружения отличается",
+ "pipeline_sync_required": "Требуется синхронизация версии",
+ "pipeline_message_resolve_drift": "Публикация перезапишет отличающееся содержимое PROD и устранит drift.",
"pipeline_validation_needed": "Ожидает проверки",
"pipeline_validated": "Проверка пройдена",
"pipeline_live": "Пользователи видят эту версию",
@@ -504,10 +508,33 @@
"feature_flow_transfer_confirm": "Изменения из черновика станут доступны в DEV. В PREPROD и PROD они не попадут, пока вы не запустите публикацию.",
"feature_flow_transferred": "передана в DEV",
"feature_flow_transfer_failed": "Не удалось передать доработку в DEV",
- "pipeline_preprod_candidate": "Проверенная версия для пользователей",
+ "pipeline_preprod_candidate": "Кандидат на публикацию",
+ "pipeline_candidate_validated": "проверен",
+ "pipeline_candidate_pending": "ожидает проверки",
+ "pipeline_candidate_author": "Автор",
+ "pipeline_candidate_created": "Создан",
"pipeline_dev_ahead": "В DEV есть более новые изменения",
"pipeline_next_action": "Следующее действие",
"workspace_pending_title": "Есть несохранённые изменения",
"workspace_pending_hint": "{count} файлов изменены. Они не войдут в текущую публикацию, пока вы не сохраните новую версию.",
- "workspace_pending_action": "Сохранить новую версию"
+ "workspace_pending_action": "Перейти к сохранению",
+ "workspace_save_title": "Сохранить версию",
+ "workspace_save_hint": "Опишите смысл изменений — это описание увидят при проверке и публикации.",
+ "workspace_version_description": "Описание версии",
+ "workspace_save_button": "Сохранить версию",
+ "workspace_save_options": "Параметры сохранения",
+ "workspace_refresh_changes": "Обновить изменения",
+ "workspace_description_required": "Добавьте описание версии, чтобы сохранить её.",
+ "workspace_changes_title": "Изменения версии",
+ "semantic_key_changes": "Ключевые изменения",
+ "workspace_summary_loading": "Агент анализирует изменения…",
+ "workspace_summary_loading_hint": "Это может занять несколько минут. Можно продолжать работу с версией.",
+ "workspace_summary_ai_notice": "Описание сформировано AI по текущему diff.",
+ "workspace_summary_regenerate": "Сформулировать иначе",
+ "workspace_summary_failed": "Не удалось описать изменения",
+ "workspace_summary_failed_hint": "Проверьте настройку LLM и повторите запрос.",
+ "workspace_summary_retry": "Повторить",
+ "workspace_summary_generate": "Описать изменения с помощью AI",
+ "technical_yaml_preview": "Технический YAML diff",
+ "open_raw_diff": "Открыть текстовый diff"
}
diff --git a/frontend/src/lib/models/GitManagerModel.svelte.ts b/frontend/src/lib/models/GitManagerModel.svelte.ts
index 0fbca2c67..646f478c5 100644
--- a/frontend/src/lib/models/GitManagerModel.svelte.ts
+++ b/frontend/src/lib/models/GitManagerModel.svelte.ts
@@ -18,6 +18,7 @@
// @ACTION loadWorkspace() — Loads workspace status and diff.
// @ACTION handleSync() — Synchronizes dashboard state with Git.
// @ACTION handleGenerateMessage() — Generates AI commit message from diff.
+// @ACTION handleGenerateWorkspaceSummary() — Generates a non-blocking BI explanation of the current diff.
// @ACTION handleCommit() — Stages and commits workspace changes.
// @ACTION handlePromote(...) — Promotes changes between branches.
// @ACTION handlePull() — Pulls from remote.
@@ -67,8 +68,10 @@ import {
applyGitflowStageDefaults,
resolvePushProviderLabel,
extractHttpHost,
+ getSemanticWorkspaceFiles,
+ filterTechnicalWorkspaceDiff,
} from '../../services/git-utils.js';
-import { getT } from '$lib/i18n/index.svelte.js';
+import { getT, locale } from '$lib/i18n/index.svelte.js';
import { log } from '$lib/cot-logger';
// ── Types ─────────────────────────────────────────────────────
@@ -275,6 +278,11 @@ export class GitManagerModel {
workspaceStatus: WorkspaceStatus | null = $state(null);
workspaceDiff: string = $state('');
workspaceLoading: boolean = $state(false);
+ workspaceSummary: string = $state('');
+ workspaceSummaryState: 'idle' | 'loading' | 'ready' | 'error' = $state('idle');
+ workspaceSummaryError: string = $state('');
+ private _workspaceSummaryDiff: string = '';
+ private _workspaceSummaryRequestVersion: number = 0;
// ── Pull / Push ─────────────────────────────────────────────
isPulling: boolean = $state(false);
@@ -356,16 +364,12 @@ export class GitManagerModel {
/** True when there are any workspace changes (staged, modified, or untracked). */
hasWorkspaceChanges: boolean = $derived.by(() => {
- if (!this.workspaceStatus) return false;
- const w = this.workspaceStatus;
- return [...(w.staged_files || []), ...(w.modified_files || []), ...(w.untracked_files || [])].length > 0;
+ return getSemanticWorkspaceFiles(this.workspaceStatus).length > 0;
});
/** Total count of changed files (staged + modified + untracked). */
changedFilesCount: number = $derived.by(() => {
- if (!this.workspaceStatus) return 0;
- const w = this.workspaceStatus;
- return [...(w.staged_files || []), ...(w.modified_files || []), ...(w.untracked_files || [])].length;
+ return getSemanticWorkspaceFiles(this.workspaceStatus).length;
});
/** True when the current stage has a valid next promotion target. */
@@ -698,9 +702,10 @@ export class GitManagerModel {
const sd: string = await gitService.getDiff(this.dashboardId, null, true, this.resolvedEnvId);
const ud: string = await gitService.getDiff(this.dashboardId, null, false, this.resolvedEnvId);
this.workspaceStatus = ws;
- this.workspaceDiff = [sd, ud].filter(Boolean).join('\n\n');
+ this.workspaceDiff = filterTechnicalWorkspaceDiff([sd, ud].filter(Boolean).join('\n\n'));
this.currentBranch = ws?.current_branch || this.currentBranch;
this.applyPromotionDefaultsForCurrentBranch();
+ void this.handleGenerateWorkspaceSummary();
} catch (e: unknown) {
this._setGitError(e);
} finally {
@@ -759,6 +764,73 @@ export class GitManagerModel {
}
}
+ // #region Git.ManagerModel.GenerateWorkspaceSummary [C:4] [TYPE Function] [SEMANTICS git,llm,summary,workspace]
+ // @ingroup Git
+ // @BRIEF Request an LLM-authored BI explanation without blocking save, sync, or navigation.
+ // @PRE workspaceDiff is the currently rendered semantic diff.
+ // @POST Only a response for the still-current diff may transition the summary to ready.
+ // @SIDE_EFFECT Calls the configured Git LLM endpoint; request may remain active for up to three minutes.
+ // @INVARIANT A stale response can never replace the summary for a newer workspace diff.
+ // @UX_STATE loading -> Inline progress remains visible while the rest of the workspace stays interactive.
+ // @UX_RECOVERY error -> User can retry; force=true deliberately asks the LLM for another formulation.
+ async handleGenerateWorkspaceSummary(force = false): Promise {
+ const requestedDiff = this.workspaceDiff;
+ if (!requestedDiff.trim()) {
+ this._workspaceSummaryRequestVersion += 1;
+ this._workspaceSummaryDiff = '';
+ this.workspaceSummary = '';
+ this.workspaceSummaryError = '';
+ this.workspaceSummaryState = 'idle';
+ return;
+ }
+ if (!force && this._workspaceSummaryDiff === requestedDiff && ['loading', 'ready'].includes(this.workspaceSummaryState)) {
+ return;
+ }
+
+ const requestVersion = ++this._workspaceSummaryRequestVersion;
+ this._workspaceSummaryDiff = requestedDiff;
+ this.workspaceSummary = '';
+ this.workspaceSummaryError = '';
+ this.workspaceSummaryState = 'loading';
+ log('GitManagerModel.handleGenerateWorkspaceSummary', 'REASON', 'Request BI-facing workspace summary', {
+ diffChars: requestedDiff.length,
+ force,
+ });
+ try {
+ const language = locale.current === 'en' ? 'English' : 'Russian';
+ const query = new URLSearchParams({
+ purpose: 'summary',
+ language,
+ ...(this.resolvedEnvId ? { env_id: String(this.resolvedEnvId) } : {}),
+ });
+ const data: { summary?: string } = await api.postApi(
+ `/git/repositories/${encodeURIComponent(String(this.dashboardId))}/generate-message?${query.toString()}`,
+ undefined,
+ { suppressToast: true, signal: AbortSignal.timeout(180_000) },
+ );
+ if (requestVersion !== this._workspaceSummaryRequestVersion || requestedDiff !== this.workspaceDiff) return;
+ const summary = String(data?.summary || '').trim();
+ if (!summary) throw new Error('LLM returned an empty change summary');
+ this.workspaceSummary = summary;
+ this.workspaceSummaryState = 'ready';
+ log('GitManagerModel.handleGenerateWorkspaceSummary', 'REFLECT', 'BI-facing workspace summary is current', {
+ summaryChars: summary.length,
+ });
+ } catch (e: unknown) {
+ if (requestVersion !== this._workspaceSummaryRequestVersion || requestedDiff !== this.workspaceDiff) return;
+ this.workspaceSummaryError = e instanceof Error ? e.message : 'Summary generation failed';
+ this.workspaceSummaryState = 'error';
+ log(
+ 'GitManagerModel.handleGenerateWorkspaceSummary',
+ 'EXPLORE',
+ 'BI-facing workspace summary unavailable',
+ { diffChars: requestedDiff.length },
+ this.workspaceSummaryError,
+ );
+ }
+ }
+ // #endregion Git.ManagerModel.GenerateWorkspaceSummary
+
// ── Commit ───────────────────────────────────────────────────
/**
diff --git a/frontend/src/lib/models/__tests__/GitManagerModel.test.ts b/frontend/src/lib/models/__tests__/GitManagerModel.test.ts
index 370af3acd..667821c43 100644
--- a/frontend/src/lib/models/__tests__/GitManagerModel.test.ts
+++ b/frontend/src/lib/models/__tests__/GitManagerModel.test.ts
@@ -22,7 +22,7 @@ vi.mock("../../../services/gitService.js", () => ({
}));
vi.mock("$lib/api.js", () => ({ api: { getEnvironmentsList: vi.fn(), postApi: vi.fn() } }));
vi.mock('$lib/toasts.svelte.js', () => ({ addToast: vi.fn() }));
-vi.mock('$lib/i18n/index.svelte.js', () => ({ t: { subscribe: vi.fn() }, _: vi.fn(), getT: vi.fn(() => ({ git: { sync_success: "Synced", commit_success: "Committed", commit_and_push_success: "Commit & Push OK", commit_message_generated: "Generated", pull_success: "Pulled", push_success: "Pushed", init_success: "Init OK", init_validation_error: "Fill all fields", no_servers_configured: "No servers", repo_already_exists: "Already exists" } })) }));
+vi.mock('$lib/i18n/index.svelte.js', () => ({ t: { subscribe: vi.fn() }, _: vi.fn(), locale: { current: 'ru' }, getT: vi.fn(() => ({ git: { sync_success: "Synced", commit_success: "Committed", commit_and_push_success: "Commit & Push OK", commit_message_generated: "Generated", pull_success: "Pulled", push_success: "Pushed", init_success: "Init OK", init_validation_error: "Fill all fields", no_servers_configured: "No servers", repo_already_exists: "Already exists" } })) }));
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
vi.mock("svelte/store", () => ({ get: vi.fn(() => ({ git: {} })) }));
@@ -55,7 +55,20 @@ describe("GitManagerModel — L1 invariants (no render)", () => {
it("hasWorkspaceChanges true when modified", () => { model.workspaceStatus = makeWs({ modified_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); });
it("hasWorkspaceChanges true when untracked", () => { model.workspaceStatus = makeWs({ untracked_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); });
it("hasWorkspaceChanges false when empty", () => { model.workspaceStatus = makeWs(); expect(model.hasWorkspaceChanges).toBe(false); });
- it("changedFilesCount aggregates all", () => { model.workspaceStatus = makeWs({ staged_files: ["a", "b"], modified_files: ["c"], untracked_files: ["d", "e"] }); expect(model.changedFilesCount).toBe(5); });
+ it("changedFilesCount aggregates unique semantic files", () => { model.workspaceStatus = makeWs({ staged_files: ["a", "b"], modified_files: ["b", "c"], untracked_files: ["d", "e"] }); expect(model.changedFilesCount).toBe(5); });
+ it("hides integration artifacts from BI workspace counts", () => {
+ model.workspaceStatus = makeWs({
+ staged_files: ["dashboards/sales.yaml", "metadata.yaml"],
+ modified_files: ["dashboards/sales.yaml", ".superset-tools-fingerprint"],
+ });
+ expect(model.changedFilesCount).toBe(1);
+ expect(model.hasWorkspaceChanges).toBe(true);
+ });
+ it("does not offer a BI commit for integration-only changes", () => {
+ model.workspaceStatus = makeWs({ modified_files: ["metadata.yaml", ".superset-tools-fingerprint"] });
+ expect(model.changedFilesCount).toBe(0);
+ expect(model.hasWorkspaceChanges).toBe(false);
+ });
it("changedFilesCount 0 when null", () => { model.workspaceStatus = null; expect(model.changedFilesCount).toBe(0); });
it("changedFilesCount 0 when empty", () => { model.workspaceStatus = makeWs(); expect(model.changedFilesCount).toBe(0); });
it("resolvedEnvId returns envId", () => { model.envId = "env-456"; expect(model.resolvedEnvId).toBe("env-456"); });
@@ -241,6 +254,43 @@ describe("GitManagerModel — L1 invariants (no render)", () => {
});
});
+ describe("handleGenerateWorkspaceSummary — asynchronous LLM state", () => {
+ it("stores a free-form summary returned for the current diff", async () => {
+ model.workspaceDiff = "diff --git a/dashboards/world.yaml b/dashboards/world.yaml\n-old\n+new";
+ vi.mocked(api.postApi).mockResolvedValue({ summary: "Обновлены структура и представление дашборда." });
+
+ await model.handleGenerateWorkspaceSummary();
+
+ expect(api.postApi).toHaveBeenCalledWith(
+ expect.stringContaining("purpose=summary"),
+ undefined,
+ expect.objectContaining({ suppressToast: true, signal: expect.any(AbortSignal) }),
+ );
+ expect(model.workspaceSummary).toBe("Обновлены структура и представление дашборда.");
+ expect(model.workspaceSummaryState).toBe("ready");
+ });
+
+ it("keeps summary failure local and recoverable", async () => {
+ model.workspaceDiff = "diff --git a/charts/kpi.yaml b/charts/kpi.yaml\n-old\n+new";
+ vi.mocked(api.postApi).mockRejectedValue(new Error("Provider timeout"));
+
+ await model.handleGenerateWorkspaceSummary();
+
+ expect(model.workspaceSummaryState).toBe("error");
+ expect(model.workspaceSummaryError).toBe("Provider timeout");
+ expect(model.gitError).toBeNull();
+ });
+
+ it("does not call the LLM when the workspace has no diff", async () => {
+ model.workspaceDiff = "";
+
+ await model.handleGenerateWorkspaceSummary();
+
+ expect(api.postApi).not.toHaveBeenCalled();
+ expect(model.workspaceSummaryState).toBe("idle");
+ });
+ });
+
describe("checkStatus — guard paths", () => {
it("blocks numeric dashboardId", async () => {
model.dashboardId = "12345"; await model.checkStatus();
diff --git a/frontend/src/services/__tests__/git-utils.workspace.test.ts b/frontend/src/services/__tests__/git-utils.workspace.test.ts
new file mode 100644
index 000000000..140ecec3e
--- /dev/null
+++ b/frontend/src/services/__tests__/git-utils.workspace.test.ts
@@ -0,0 +1,58 @@
+// #region Test.Git.WorkspaceUtils [C:2] [TYPE Module] [SEMANTICS test,git,workspace,bi]
+// @BRIEF Technical Superset artifacts never compete with semantic dashboard changes in BI UI.
+// @TEST_INVARIANT SemanticFileCount -> duplicate Git states count once and technical files count zero.
+// @TEST_INVARIANT SemanticDiff -> technical file chunks are removed while dashboard chunks stay intact.
+import { describe, expect, it } from 'vitest';
+import {
+ filterTechnicalWorkspaceDiff,
+ getSemanticWorkspaceFiles,
+ isTechnicalWorkspaceFile,
+} from '../git-utils.js';
+
+describe('Git workspace BI projection', () => {
+ it('recognizes only root integration artifacts as technical', () => {
+ expect(isTechnicalWorkspaceFile('metadata.yaml')).toBe(true);
+ expect(isTechnicalWorkspaceFile('a/.superset-tools-fingerprint')).toBe(true);
+ expect(isTechnicalWorkspaceFile('dashboards/metadata.yaml')).toBe(false);
+ });
+
+ it('deduplicates semantic files across Git status groups', () => {
+ expect(getSemanticWorkspaceFiles({
+ staged_files: ['dashboards/sales.yaml', 'metadata.yaml'],
+ modified_files: ['dashboards/sales.yaml', '.superset-tools-fingerprint'],
+ untracked_files: ['charts/revenue.yaml'],
+ })).toEqual(['dashboards/sales.yaml', 'charts/revenue.yaml']);
+ });
+
+ it('removes technical chunks from a multi-file diff', () => {
+ const diff = [
+ 'diff --git a/.superset-tools-fingerprint b/.superset-tools-fingerprint',
+ 'index 111..222 100644',
+ '--- a/.superset-tools-fingerprint',
+ '+++ b/.superset-tools-fingerprint',
+ '@@ -1 +1 @@',
+ '-old',
+ '+new',
+ 'diff --git a/dashboards/sales.yaml b/dashboards/sales.yaml',
+ 'index 333..444 100644',
+ '--- a/dashboards/sales.yaml',
+ '+++ b/dashboards/sales.yaml',
+ '@@ -1 +1 @@',
+ '-title: Old',
+ '+title: New',
+ 'diff --git a/metadata.yaml b/metadata.yaml',
+ 'index 555..666 100644',
+ '--- a/metadata.yaml',
+ '+++ b/metadata.yaml',
+ '@@ -1 +1 @@',
+ '-timestamp: old',
+ '+timestamp: new',
+ ].join('\n');
+
+ const result = filterTechnicalWorkspaceDiff(diff);
+ expect(result).toContain('dashboards/sales.yaml');
+ expect(result).not.toContain('.superset-tools-fingerprint');
+ expect(result).not.toContain('metadata.yaml');
+ });
+});
+// #endregion Test.Git.WorkspaceUtils
diff --git a/frontend/src/services/git-utils.ts b/frontend/src/services/git-utils.ts
index 3abc7c4a4..78f823803 100644
--- a/frontend/src/services/git-utils.ts
+++ b/frontend/src/services/git-utils.ts
@@ -114,6 +114,67 @@ export function extractHttpHost(urlValue: string | null | undefined): string {
}
}
+/** Files maintained by the Git/Superset integration rather than by a BI author. */
+const TECHNICAL_WORKSPACE_FILES = new Set([
+ '.superset-tools-fingerprint',
+ 'metadata.yaml',
+ 'metadata.yml',
+]);
+
+/** Return true when a workspace path is an integration artifact, not a dashboard change. */
+export function isTechnicalWorkspaceFile(pathValue: string | null | undefined): boolean {
+ const normalized = String(pathValue || '')
+ .trim()
+ .replace(/^(?:a|b)\//, '')
+ .replace(/^\.\//, '');
+ return TECHNICAL_WORKSPACE_FILES.has(normalized);
+}
+
+/**
+ * Return unique BI-facing changed files across staged, modified and untracked sets.
+ * A path can be present in more than one Git set; it must still count as one file.
+ */
+export function getSemanticWorkspaceFiles(status: {
+ staged_files?: string[];
+ modified_files?: string[];
+ untracked_files?: string[];
+} | null | undefined): string[] {
+ if (!status) return [];
+ return Array.from(new Set([
+ ...(status.staged_files || []),
+ ...(status.modified_files || []),
+ ...(status.untracked_files || []),
+ ].map((path) => String(path || '').trim()).filter(Boolean)))
+ .filter((path) => !isTechnicalWorkspaceFile(path));
+}
+
+/** Remove integration-only file sections from a unified Git diff shown to BI users. */
+export function filterTechnicalWorkspaceDiff(diffValue: string | null | undefined): string {
+ const diff = String(diffValue || '');
+ if (!diff.trim()) return '';
+ const header = 'diff --git ';
+ const starts: number[] = [];
+ let cursor = 0;
+ while (cursor < diff.length) {
+ const index = diff.indexOf(header, cursor);
+ if (index < 0) break;
+ starts.push(index);
+ cursor = index + header.length;
+ }
+ if (starts.length === 0) return diff;
+
+ const prefix = diff.slice(0, starts[0]);
+ const kept = starts.flatMap((start, index) => {
+ const end = starts[index + 1] ?? diff.length;
+ const chunk = diff.slice(start, end);
+ const firstLine = chunk.slice(0, chunk.indexOf('\n') >= 0 ? chunk.indexOf('\n') : chunk.length);
+ const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(firstLine);
+ const paths = match ? [match[1], match[2]] : [];
+ return paths.length > 0 && paths.every(isTechnicalWorkspaceFile) ? [] : [chunk];
+ });
+ return `${kept.length ? prefix : ''}${kept.join('')}`.trim();
+}
+
/**
* Build deterministic repository name from dashboard title/id.
*/