From 3619a2ae78631e08a5676404272b2ee7072b278b Mon Sep 17 00:00:00 2001 From: busya Date: Wed, 27 May 2026 18:05:28 +0300 Subject: [PATCH] feat(assistant): implement tool registry and expand assistant capabilities Introduce a centralized tool registry system for the assistant to manage executable operations, permissions, and tool catalogs. This refactors the assistant dispatch logic from a monolithic approach to a modular, decorator-based registry. Key changes: - Implement `AssistantToolRegistry` to handle tool registration, permission checks, and safe operation identification. - Add a suite of new assistant tools: backup, commit, branch creation, deployment, health summary, environment listing, LLM operations, maintenance, migration, and dashboard searching. - Refactor `_dispatch.py` and `_llm_planner.py` to utilize the new registry for intent resolution and tool catalog building. - Enhance the LLM planner to support more descriptive clarification responses in Russian when intents are ambiguous. - Update the frontend to support the new tool-based workflow, including improved i18n labels for assistant operations and a new step-by-step wizard for the migration page. - Add ADR-0008 to document the architectural decision for the tool registry. --- backend/src/api/routes/assistant/__init__.py | 38 +- .../api/routes/assistant/_command_parser.py | 18 + backend/src/api/routes/assistant/_dispatch.py | 256 ++---- .../src/api/routes/assistant/_llm_planner.py | 191 +--- .../routes/assistant/_llm_planner_intent.py | 13 +- backend/src/api/routes/assistant/_routes.py | 21 +- .../src/api/routes/assistant/_tool_backup.py | 100 ++ .../routes/assistant/_tool_capabilities.py | 67 ++ .../src/api/routes/assistant/_tool_commit.py | 56 ++ .../routes/assistant/_tool_create_branch.py | 59 ++ .../src/api/routes/assistant/_tool_deploy.py | 77 ++ .../routes/assistant/_tool_health_summary.py | 81 ++ .../assistant/_tool_list_environments.py | 65 ++ backend/src/api/routes/assistant/_tool_llm.py | 127 +++ .../assistant/_tool_llm_documentation.py | 80 ++ .../routes/assistant/_tool_llm_validation.py | 97 ++ .../api/routes/assistant/_tool_maintenance.py | 351 +++++++ .../api/routes/assistant/_tool_migration.py | 118 +++ .../api/routes/assistant/_tool_registry.py | 268 ++++++ .../assistant/_tool_search_dashboards.py | 120 +++ .../api/routes/assistant/_tool_task_status.py | 79 ++ docs/adr/ADR-0008-assistant-tool-registry.md | 115 +++ frontend/src/components/EnvSelector.svelte | 2 +- .../assistant/AssistantChatPanel.svelte | 4 +- .../src/lib/i18n/locales/en/assistant.json | 17 +- .../src/lib/i18n/locales/en/dashboard.json | 1 + .../src/lib/i18n/locales/en/migration.json | 19 +- .../src/lib/i18n/locales/ru/assistant.json | 17 +- .../src/lib/i18n/locales/ru/dashboard.json | 3 +- .../src/lib/i18n/locales/ru/migration.json | 17 +- frontend/src/routes/migration/+page.svelte | 856 +++++++++++------- .../__tests__/migration_dashboard.ux.test.js | 126 ++- .../src/routes/translate/[id]/+page.svelte | 283 +++--- 33 files changed, 2892 insertions(+), 850 deletions(-) create mode 100644 backend/src/api/routes/assistant/_tool_backup.py create mode 100644 backend/src/api/routes/assistant/_tool_capabilities.py create mode 100644 backend/src/api/routes/assistant/_tool_commit.py create mode 100644 backend/src/api/routes/assistant/_tool_create_branch.py create mode 100644 backend/src/api/routes/assistant/_tool_deploy.py create mode 100644 backend/src/api/routes/assistant/_tool_health_summary.py create mode 100644 backend/src/api/routes/assistant/_tool_list_environments.py create mode 100644 backend/src/api/routes/assistant/_tool_llm.py create mode 100644 backend/src/api/routes/assistant/_tool_llm_documentation.py create mode 100644 backend/src/api/routes/assistant/_tool_llm_validation.py create mode 100644 backend/src/api/routes/assistant/_tool_maintenance.py create mode 100644 backend/src/api/routes/assistant/_tool_migration.py create mode 100644 backend/src/api/routes/assistant/_tool_registry.py create mode 100644 backend/src/api/routes/assistant/_tool_search_dashboards.py create mode 100644 backend/src/api/routes/assistant/_tool_task_status.py create mode 100644 docs/adr/ADR-0008-assistant-tool-registry.md diff --git a/backend/src/api/routes/assistant/__init__.py b/backend/src/api/routes/assistant/__init__.py index a50b73be..b81dc830 100644 --- a/backend/src/api/routes/assistant/__init__.py +++ b/backend/src/api/routes/assistant/__init__.py @@ -7,6 +7,33 @@ # @RELATION DEPENDS_ON -> [AssistantAuditRecord] # @INVARIANT Risky operations are never executed without valid confirmation token. +# ── Tool registry must be imported first to register all @assistant_tool decorators ── +from ._tool_registry import ( + _check_any_permission, + _has_any_permission, + assistant_tool, + dispatch, + get_catalog, + get_safe_ops, + _tools as _registered_tools, +) + +# ── Import all tool modules so their @assistant_tool decorators register ── +from . import _tool_capabilities +from . import _tool_task_status +from . import _tool_create_branch +from . import _tool_commit +from . import _tool_deploy +from . import _tool_migration +from . import _tool_backup +from . import _tool_llm_validation +from . import _tool_llm_documentation +from . import _tool_health_summary +from . import _tool_search_dashboards +from . import _tool_list_environments +from . import _tool_maintenance +from . import _tool_llm + # Re-export public API for backward compatibility. from ._admin_routes import delete_conversation, get_assistant_audit, get_history, list_conversations from ._command_parser import _parse_command @@ -46,7 +73,6 @@ from ._resolvers import ( from ._routes import cancel_operation, confirm_operation, router, send_message from ._schemas import ( _DATASET_REVIEW_OPS, - _SAFE_OPS, ASSISTANT_AUDIT, CONFIRMATIONS, CONVERSATIONS, @@ -58,6 +84,9 @@ from ._schemas import ( ConfirmationRecord, ) +# _SAFE_OPS is now derived from the tool registry. +_SAFE_OPS = get_safe_ops() + __all__ = [ "ASSISTANT_AUDIT", "CONFIRMATIONS", @@ -66,6 +95,11 @@ __all__ = [ "USER_ACTIVE_CONVERSATION", "_DATASET_REVIEW_OPS", "_SAFE_OPS", + "_registered_tools", + "assistant_tool", + "dispatch", + "get_catalog", + "get_safe_ops", "AssistantAction", "AssistantMessageRequest", "AssistantMessageResponse", @@ -76,6 +110,7 @@ __all__ = [ "_authorize_intent", "_build_task_observability_summary", "_build_tool_catalog", + "_check_any_permission", "_clarification_text_for_intent", "_cleanup_history_ttl", "_coerce_query_bool", @@ -85,6 +120,7 @@ __all__ = [ "_extract_result_deep_links", "_get_default_environment_id", "_get_environment_name_by_id", + "_has_any_permission", "_is_conversation_archived", "_is_production_env", "_load_confirmation_from_db", diff --git a/backend/src/api/routes/assistant/_command_parser.py b/backend/src/api/routes/assistant/_command_parser.py index fb044962..23a9be47 100644 --- a/backend/src/api/routes/assistant/_command_parser.py +++ b/backend/src/api/routes/assistant/_command_parser.py @@ -80,6 +80,24 @@ def _parse_command(message: str, config_manager: ConfigManager) -> dict[str, Any provider_match = _extract_id(lower, ['(?:provider|провайдер)\\s+([a-z0-9_-]+)']) logger.reflect('Belief protocol postcondition checkpoint for _parse_command') return {'domain': 'llm', 'operation': 'run_llm_documentation', 'entities': {'dataset_id': int(dataset_id) if dataset_id else None, 'environment': env_match, 'provider': provider_match}, 'confidence': 0.88 if dataset_id else 0.64, 'risk_level': 'guarded', 'requires_confirmation': False} + # ── Новые инструменты ──────────────────────────────────────── + if any(k in lower for k in ['список дашборд', 'покажи дашборд', 'найди дашборд', 'list dashboard', 'search dashboard', 'all dashboards', 'все дашборд']): + env_match = _extract_id(lower, ['(?:в|for|env|окружени[ея])\\s+([a-z0-9_-]+)']) + search_phrase = _extract_id(lower, ['(?:найди|search|поиск)\\s+([а-яa-z0-9_-]{2,})']) + logger.reflect('Belief protocol postcondition checkpoint for _parse_command') + return {'domain': 'dashboards', 'operation': 'search_dashboards', 'entities': {'environment': env_match, 'search': search_phrase}, 'confidence': 0.88 if env_match else 0.75, 'risk_level': 'safe', 'requires_confirmation': False} + if any(k in lower for k in ['окружени', 'environment', 'среды', 'сред']): + logger.reflect('Belief protocol postcondition checkpoint for _parse_command') + return {'domain': 'environments', 'operation': 'list_environments', 'entities': {}, 'confidence': 0.9, 'risk_level': 'safe', 'requires_confirmation': False} + if any(k in lower for k in ['обслуживан', 'maintenance', 'события']): + logger.reflect('Belief protocol postcondition checkpoint for _parse_command') + return {'domain': 'maintenance', 'operation': 'list_maintenance_events', 'entities': {}, 'confidence': 0.9, 'risk_level': 'safe', 'requires_confirmation': False} + if any(k in lower for k in ['llm провайдер', 'llm provider', 'провайдеры']): + logger.reflect('Belief protocol postcondition checkpoint for _parse_command') + return {'domain': 'llm', 'operation': 'list_llm_providers', 'entities': {}, 'confidence': 0.9, 'risk_level': 'safe', 'requires_confirmation': False} + if any(k in lower for k in ['llm статус', 'llm status', 'проверь llm', 'llm провер']): + logger.reflect('Belief protocol postcondition checkpoint for _parse_command') + return {'domain': 'llm', 'operation': 'get_llm_status', 'entities': {}, 'confidence': 0.9, 'risk_level': 'safe', 'requires_confirmation': False} logger.reflect('Belief protocol postcondition checkpoint for _parse_command') return {'domain': 'unknown', 'operation': 'clarify', 'entities': {}, 'confidence': 0.3, 'risk_level': 'safe', 'requires_confirmation': False} diff --git a/backend/src/api/routes/assistant/_dispatch.py b/backend/src/api/routes/assistant/_dispatch.py index 1b2df051..e2f53299 100644 --- a/backend/src/api/routes/assistant/_dispatch.py +++ b/backend/src/api/routes/assistant/_dispatch.py @@ -1,11 +1,10 @@ -# #region AssistantDispatch [C:5] [TYPE Module] [SEMANTICS assistant, dispatch, confirm, execution, orchestration] -# @BRIEF Intent dispatch engine, confirmation summary, and clarification text for the assistant API. +# #region AssistantDispatch [C:4] [TYPE Module] [SEMANTICS assistant, dispatch, confirm, execution, orchestration] +# @BRIEF Intent dispatch engine and backward-compat wrapper around the central tool registry. # @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] # @RELATION DEPENDS_ON -> [AssistantSchemas] -# @RELATION DEPENDS_ON -> [AssistantResolvers] -# @RELATION DEPENDS_ON -> [AssistantLlmPlanner] -# @RELATION DEPENDS_ON -> [AssistantDatasetReview] # @INVARIANT Unsupported operations are rejected via HTTPException(400). +# @INVARIANT All tool handlers live in _tool_registry — _dispatch is a thin wrapper. from __future__ import annotations @@ -19,37 +18,30 @@ from src.core.logger import belief_scope, logger from src.core.task_manager import TaskManager from src.schemas.auth import User from src.services.git_service import GitService -from src.services.llm_provider import LLMProviderService -from ._dataset_review_dispatch import _dispatch_dataset_review_intent from ._history import _coerce_query_bool -from ._llm_planner import _check_any_permission from ._resolvers import ( - _build_task_observability_summary, - _extract_result_deep_links, _get_environment_name_by_id, _resolve_dashboard_id_entity, _resolve_env_id, - _resolve_provider_id, -) -from ._schemas import ( - _DATASET_REVIEW_OPS, - AssistantAction, ) +from ._schemas import AssistantAction +from ._tool_registry import dispatch as _registry_dispatch # #region _get_git_service [TYPE Function] # @BRIEF Lazy-init GitService singleton to avoid crash at module import time when /app/storage/ is unavailable. # @POST Returns GitService instance (created once, cached). # @SIDE_EFFECT May attempt to create /app/storage/repositories on first call. -# @RATIONALE Module-level GitService() instantiation crashed test collection for 33+ test files -# because /app/storage/ doesn't exist outside Docker — see test fix. _git_service_instance: GitService | None = None + def _get_git_service() -> GitService: global _git_service_instance if _git_service_instance is None: _git_service_instance = GitService() return _git_service_instance + + # #endregion _get_git_service @@ -90,17 +82,75 @@ async def _async_confirmation_summary(intent: dict[str, Any], config_manager: Co logger.reason('Belief protocol reasoning checkpoint for _confirmation_summary') operation = intent.get('operation', '') entities = intent.get('entities', {}) - descriptions: dict[str, str] = {'create_branch': 'создание ветки{branch} для дашборда{dashboard}', 'commit_changes': 'коммит изменений для дашборда{dashboard}', 'deploy_dashboard': 'деплой дашборда{dashboard} в окружение{env}', 'execute_migration': 'миграция дашборда{dashboard} с{src} на{tgt}', 'run_backup': 'бэкап окружения{env}{dashboard}', 'run_llm_validation': 'LLM-валидация дашборда{dashboard}{env}', 'run_llm_documentation': 'генерация документации для датасета{dataset}{env}'} - template = descriptions.get(operation) - if not template: + descriptions: dict[str, tuple[str, str]] = { + 'create_branch': ( + '📝 Создание ветки в Git', + 'Будет создана ветка **{branch}** для дашборда **{dashboard}**. ' + 'Изменения можно будет коммитить в эту ветку.' + ), + 'commit_changes': ( + '📝 Коммит изменений в Git', + 'Изменения дашборда **{dashboard}** будут закоммичены в Git-репозиторий. ' + 'После коммита их можно будет развернуть на других окружениях.' + ), + 'deploy_dashboard': ( + '🚀 Деплой дашборда', + 'Дашборд **{dashboard}** будет развёрнут в окружение **{env}**. ' + 'Изменения станут доступны на целевом окружении.' + ), + 'execute_migration': ( + '🔄 Миграция дашборда', + 'Дашборд **{dashboard}** будет перенесён с **{src}** на **{tgt}**.' + ), + 'run_backup': ( + '💾 Бэкап', + 'Будет запущено резервное копирование окружения **{env}**{dashboard}.' + ), + 'run_llm_validation': ( + '🤖 LLM-валидация дашборда', + 'Будет запущена LLM-валидация дашборда **{dashboard}**{env}. ' + 'Это займёт некоторое время.' + ), + 'run_llm_documentation': ( + '📄 Генерация документации', + 'Будет сгенерирована документация для датасета **{dataset}**{env}.' + ), + 'start_maintenance': ( + '🛠 Обслуживание', + 'Будет запущено окно обслуживания для таблиц **{tables}**.' + ), + 'end_maintenance': ( + '✅ Завершение обслуживания', + 'Обслуживание будет завершено{event}.' + ), + } + entry = descriptions.get(operation) + if not entry: logger.reflect('Belief protocol postcondition checkpoint for _confirmation_summary') return 'Подтвердите выполнение операции или отмените.' + title, desc = entry + def _label(value: Any, prefix: str=' ') -> str: logger.reflect('Belief protocol postcondition checkpoint for _confirmation_summary') return f'{prefix}{value}' if value else '' dashboard = entities.get('dashboard_id') or entities.get('dashboard_ref') - text = template.format(branch=_label(entities.get('branch_name')), dashboard=_label(dashboard), env=_label(entities.get('environment') or entities.get('target_env')), src=_label(entities.get('source_env')), tgt=_label(entities.get('target_env')), dataset=_label(entities.get('dataset_id'))) + env_val = entities.get('environment') or entities.get('target_env') + dataset_val = entities.get('dataset_id') + event_val = entities.get('event_id') + tables_val = entities.get('tables') + tables_str = ', '.join(tables_val[:5]) if isinstance(tables_val, list) else str(tables_val or '') + text = desc.format( + branch=_label(entities.get('branch_name')), + dashboard=_label(dashboard), + env=_label(env_val), + src=_label(entities.get('source_env')), + tgt=_label(entities.get('target_env')), + dataset=_label(dataset_val), + tables=tables_str if tables_str else '?', + event=_label(event_val), + ) + text = f'**{title}**\n\n{text}' if operation == 'execute_migration': flags = [] flags.append('маппинг БД: ' + ('ВКЛ' if _coerce_query_bool(entities.get('replace_db_config', False)) else 'ВЫКЛ')) @@ -150,165 +200,13 @@ async def _async_confirmation_summary(intent: dict[str, Any], config_manager: Co # #endregion _async_confirmation_summary -# #region _dispatch_intent [C:5] [TYPE Function] -# @BRIEF Execute parsed assistant intent via existing task/plugin/git services. -# @DATA_CONTRACT Input[intent,current_user,task_manager,config_manager,db] -> Output[Tuple[text:str,task_id:Optional[str],actions:List[AssistantAction]]] -# @RELATION DEPENDS_ON -> [_check_any_permission] -# @RELATION DEPENDS_ON -> [_resolve_dashboard_id_entity] -# @RELATION DEPENDS_ON -> [TaskManager] -# @RELATION DEPENDS_ON -> [GitService] -# @SIDE_EFFECT May enqueue tasks, invoke git operations, and query/update external service state. -# @PRE intent operation is known and actor permissions are validated per operation. -# @POST Returns response text, optional task id, and UI actions for follow-up. -# @INVARIANT unsupported operations are rejected via HTTPException(400). +# #region _dispatch_intent [C:1] [TYPE Function] +# @BRIEF Backward-compat wrapper around the central tool registry dispatch. +# @DEPRECATED Use `dispatch()` from `_tool_registry` directly. async def _dispatch_intent(intent: dict[str, Any], current_user: User, task_manager: TaskManager, config_manager: ConfigManager, db: Session) -> tuple[str, str | None, list[AssistantAction]]: - with belief_scope('_dispatch_intent'): - logger.reason('Belief protocol reasoning checkpoint for _dispatch_intent') - operation = intent.get('operation') - entities = intent.get('entities', {}) - if operation in _DATASET_REVIEW_OPS or operation == 'dataset_review_answer_context': - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return await _dispatch_dataset_review_intent(intent, current_user, config_manager, db) - if operation == 'show_capabilities': - from ._llm_planner import _build_tool_catalog - tools_catalog = _build_tool_catalog(current_user, config_manager, db) - labels = {'create_branch': 'Git: создание ветки', 'commit_changes': 'Git: коммит', 'deploy_dashboard': 'Git: деплой дашборда', 'execute_migration': 'Миграции: запуск переноса', 'run_backup': 'Бэкапы: запуск резервного копирования', 'run_llm_validation': 'LLM: валидация дашборда', 'run_llm_documentation': 'LLM: генерация документации', 'get_task_status': 'Статус: проверка задачи', 'get_health_summary': 'Здоровье: сводка по дашбордам'} - available = [labels[t['operation']] for t in tools_catalog if t['operation'] in labels] - if not available: - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return ('Сейчас нет доступных для вас операций ассистента.', None, []) - commands = '\n'.join(f'- {item}' for item in available) - text = f'Вот что я могу сделать для вас:\n{commands}\n\nПример: `запусти миграцию с dev на prod для дашборда 42`.' - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return (text, None, []) - if operation == 'get_health_summary': - from src.services.health_service import HealthService - env_token = entities.get('environment') - env_id = _resolve_env_id(env_token, config_manager) - service = HealthService(db) - summary = await service.get_health_summary(environment_id=env_id) - env_name = _get_environment_name_by_id(env_id, config_manager) if env_id else 'всех окружений' - text = f'Сводка здоровья дашбордов для {env_name}:\n- ✅ Прошли проверку: {summary.pass_count}\n- ⚠️ С предупреждениями: {summary.warn_count}\n- ❌ Ошибки валидации: {summary.fail_count}\n- ❓ Неизвестно: {summary.unknown_count}' - actions = [AssistantAction(type='open_route', label='Открыть Health Center', target='/dashboards/health')] - if summary.fail_count > 0: - text += '\n\nОбнаружены ошибки в следующих дашбордах:' - for item in summary.items: - if item.status == 'FAIL': - text += f"\n- {item.dashboard_id} ({item.environment_id}): {item.summary or 'Нет деталей'}" - actions.append(AssistantAction(type='open_route', label=f'Отчет {item.dashboard_id}', target=f'/reports/llm/{item.task_id}')) - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return (text, None, actions[:5]) - if operation == 'get_task_status': - _check_any_permission(current_user, [('tasks', 'READ')]) - task_id = entities.get('task_id') - if not task_id: - recent = [t for t in task_manager.get_tasks(limit=20, offset=0) if t.user_id == current_user.id] - if not recent: - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return ('У вас пока нет задач в истории.', None, []) - task = recent[0] - actions = [AssistantAction(type='open_task', label='Open Task', target=task.id)] - if str(task.status).upper() in {'SUCCESS', 'FAILED'}: - actions.extend(_extract_result_deep_links(task, config_manager)) - summary_line = _build_task_observability_summary(task, config_manager) - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return (f'Последняя задача: {task.id}, статус: {task.status}.' + (f'\n{summary_line}' if summary_line else ''), task.id, actions) - task = task_manager.get_task(task_id) - if not task: - raise HTTPException(status_code=404, detail=f'Task {task_id} not found') - actions = [AssistantAction(type='open_task', label='Open Task', target=task.id)] - if str(task.status).upper() in {'SUCCESS', 'FAILED'}: - actions.extend(_extract_result_deep_links(task, config_manager)) - summary_line = _build_task_observability_summary(task, config_manager) - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return (f'Статус задачи {task.id}: {task.status}.' + (f'\n{summary_line}' if summary_line else ''), task.id, actions) - if operation == 'create_branch': - _check_any_permission(current_user, [('plugin:git', 'EXECUTE')]) - dashboard_id = _resolve_dashboard_id_entity(entities, config_manager) - branch_name = entities.get('branch_name') - if not dashboard_id or not branch_name: - raise HTTPException(status_code=422, detail='Missing dashboard_id/dashboard_ref or branch_name') - _get_git_service().create_branch(dashboard_id, branch_name, 'main') - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return (f'Ветка `{branch_name}` создана для дашборда {dashboard_id}.', None, []) - if operation == 'commit_changes': - _check_any_permission(current_user, [('plugin:git', 'EXECUTE')]) - dashboard_id = _resolve_dashboard_id_entity(entities, config_manager) - commit_message = entities.get('message') - if not dashboard_id: - raise HTTPException(status_code=422, detail='Missing dashboard_id/dashboard_ref') - _get_git_service().commit_changes(dashboard_id, commit_message, None) - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return ('Коммит выполнен успешно.', None, []) - if operation == 'deploy_dashboard': - _check_any_permission(current_user, [('plugin:git', 'EXECUTE')]) - env_token = entities.get('environment') - env_id = _resolve_env_id(env_token, config_manager) - dashboard_id = _resolve_dashboard_id_entity(entities, config_manager, env_hint=env_token) - if not dashboard_id or not env_id: - raise HTTPException(status_code=422, detail='Missing dashboard_id/dashboard_ref or environment') - task = await task_manager.create_task(plugin_id='git-integration', params={'operation': 'deploy', 'dashboard_id': dashboard_id, 'environment_id': env_id}, user_id=current_user.id) - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return (f'Деплой запущен. task_id={task.id}', task.id, [AssistantAction(type='open_task', label='Open Task', target=task.id), AssistantAction(type='open_reports', label='Open Reports', target='/reports')]) - if operation == 'execute_migration': - _check_any_permission(current_user, [('plugin:migration', 'EXECUTE'), ('plugin:superset-migration', 'EXECUTE')]) - src_token = entities.get('source_env') - dashboard_ref = entities.get('dashboard_ref') - dashboard_id = _resolve_dashboard_id_entity(entities, config_manager, env_hint=src_token) - src = _resolve_env_id(src_token, config_manager) - tgt = _resolve_env_id(entities.get('target_env'), config_manager) - if not src or not tgt: - raise HTTPException(status_code=422, detail='Missing source_env/target_env') - if not dashboard_id and (not dashboard_ref): - raise HTTPException(status_code=422, detail='Missing dashboard_id/dashboard_ref') - migration_params: dict[str, Any] = {'source_env_id': src, 'target_env_id': tgt, 'replace_db_config': _coerce_query_bool(entities.get('replace_db_config', False)), 'fix_cross_filters': _coerce_query_bool(entities.get('fix_cross_filters', True))} - if dashboard_id: - migration_params['selected_ids'] = [dashboard_id] - else: - migration_params['dashboard_regex'] = str(dashboard_ref) - task = await task_manager.create_task(plugin_id='superset-migration', params=migration_params, user_id=current_user.id) - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return (f'Миграция запущена. task_id={task.id}', task.id, [AssistantAction(type='open_task', label='Open Task', target=task.id), AssistantAction(type='open_reports', label='Open Reports', target='/reports'), *([AssistantAction(type='open_route', label=f'Открыть дашборд в {_get_environment_name_by_id(tgt, config_manager)}', target=f'/dashboards/{dashboard_id}?env_id={tgt}'), AssistantAction(type='open_diff', label='Показать Diff', target=str(dashboard_id))] if dashboard_id else [])]) - if operation == 'run_backup': - _check_any_permission(current_user, [('plugin:superset-backup', 'EXECUTE'), ('plugin:backup', 'EXECUTE')]) - env_token = entities.get('environment') - env_id = _resolve_env_id(env_token, config_manager) - if not env_id: - raise HTTPException(status_code=400, detail='Missing or unknown environment') - params: dict[str, Any] = {'environment_id': env_id} - if entities.get('dashboard_id') or entities.get('dashboard_ref'): - dashboard_id = _resolve_dashboard_id_entity(entities, config_manager, env_hint=env_token) - if not dashboard_id: - raise HTTPException(status_code=422, detail='Missing dashboard_id/dashboard_ref') - params['dashboard_ids'] = [dashboard_id] - task = await task_manager.create_task(plugin_id='superset-backup', params=params, user_id=current_user.id) - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return (f'Бэкап запущен. task_id={task.id}', task.id, [AssistantAction(type='open_task', label='Open Task', target=task.id), AssistantAction(type='open_reports', label='Open Reports', target='/reports'), *([AssistantAction(type='open_route', label=f'Открыть дашборд в {_get_environment_name_by_id(env_id, config_manager)}', target=f'/dashboards/{dashboard_id}?env_id={env_id}'), AssistantAction(type='open_diff', label='Показать Diff', target=str(dashboard_id))] if entities.get('dashboard_id') or entities.get('dashboard_ref') else [])]) - if operation == 'run_llm_validation': - _check_any_permission(current_user, [('plugin:llm_dashboard_validation', 'EXECUTE')]) - env_token = entities.get('environment') - env_id = _resolve_env_id(env_token, config_manager) or _resolve_env_id(None, config_manager) - dashboard_id = _resolve_dashboard_id_entity(entities, config_manager, env_hint=env_token) - provider_id = _resolve_provider_id(entities.get('provider'), db, config_manager=config_manager, task_key='dashboard_validation') - if not dashboard_id or not env_id or (not provider_id): - raise HTTPException(status_code=422, detail='Missing dashboard_id/environment/provider. Укажите ID/slug дашборда или окружение.') - provider = LLMProviderService(db).get_provider(provider_id) - if not provider or not bool(provider.is_multimodal): - raise HTTPException(status_code=422, detail='Selected provider model is not multimodal for dashboard validation. Выберите мультимодальную модель (например, gpt-4o).') - task = await task_manager.create_task(plugin_id='llm_dashboard_validation', params={'dashboard_id': str(dashboard_id), 'environment_id': env_id, 'provider_id': provider_id}, user_id=current_user.id) - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return (f'LLM-валидация запущена. task_id={task.id}', task.id, [AssistantAction(type='open_task', label='Open Task', target=task.id), AssistantAction(type='open_reports', label='Open Reports', target='/reports')]) - if operation == 'run_llm_documentation': - _check_any_permission(current_user, [('plugin:llm_documentation', 'EXECUTE')]) - dataset_id = entities.get('dataset_id') - env_id = _resolve_env_id(entities.get('environment'), config_manager) - provider_id = _resolve_provider_id(entities.get('provider'), db, config_manager=config_manager, task_key='documentation') - if not dataset_id or not env_id or (not provider_id): - raise HTTPException(status_code=400, detail='Missing dataset_id/environment/provider') - task = await task_manager.create_task(plugin_id='llm_documentation', params={'dataset_id': str(dataset_id), 'environment_id': env_id, 'provider_id': provider_id}, user_id=current_user.id) - logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent') - return (f'Генерация документации запущена. task_id={task.id}', task.id, [AssistantAction(type='open_task', label='Open Task', target=task.id), AssistantAction(type='open_reports', label='Open Reports', target='/reports')]) - raise HTTPException(status_code=400, detail='Unsupported operation') + """Backward-compat wrapper around the central tool registry dispatch.""" + operation = intent.get('operation') + return await _registry_dispatch(operation, intent, current_user, task_manager, config_manager, db) # #endregion _dispatch_intent diff --git a/backend/src/api/routes/assistant/_llm_planner.py b/backend/src/api/routes/assistant/_llm_planner.py index 6914333b..01780ab4 100644 --- a/backend/src/api/routes/assistant/_llm_planner.py +++ b/backend/src/api/routes/assistant/_llm_planner.py @@ -14,204 +14,35 @@ from __future__ import annotations from typing import Any -from fastapi import HTTPException from sqlalchemy.orm import Session from src.core.config_manager import ConfigManager -from src.dependencies import has_permission from src.schemas.auth import User -from src.services.llm_prompt_templates import resolve_bound_provider_id -from src.services.llm_provider import LLMProviderService -from ._resolvers import ( - _get_default_environment_id, -) -from ._schemas import ( - INTENT_PERMISSION_CHECKS, +from ._tool_registry import ( + _check_any_permission, + _has_any_permission, + get_catalog, ) -# #region _check_any_permission [C:2] [TYPE Function] -# @BRIEF Validate user against alternative permission checks (logical OR). -# @PRE checks list contains resource-action tuples. -# @POST Returns on first successful permission; raises 403-like HTTPException otherwise. -def _check_any_permission(current_user: User, checks: list[tuple[str, str]]): - errors: list[HTTPException] = [] - for resource, action in checks: - try: - has_permission(resource, action)(current_user) - return - except HTTPException as exc: - errors.append(exc) - - raise ( - errors[-1] - if errors - else HTTPException(status_code=403, detail="Permission denied") - ) - - -# #endregion _check_any_permission - - -# #region _has_any_permission [C:2] [TYPE Function] -# @BRIEF Check whether user has at least one permission tuple from the provided list. -# @PRE current_user and checks list are valid. -# @POST Returns True when at least one permission check passes. -def _has_any_permission(current_user: User, checks: list[tuple[str, str]]) -> bool: - try: - _check_any_permission(current_user, checks) - return True - except HTTPException: - return False - - -# #endregion _has_any_permission - - -# #region _build_tool_catalog [C:3] [TYPE Function] -# @BRIEF Build current-user tool catalog for LLM planner with operation contracts and defaults. +# #region _build_tool_catalog [C:2] [TYPE Function] +# @BRIEF Build tool catalog from the central registry, appending dataset-review tools when a session is active. # @PRE current_user is authenticated; config/db are available. # @POST Returns list of executable tools filtered by permission and runtime availability. -# @RELATION CALLS -> LLMProviderService def _build_tool_catalog( current_user: User, config_manager: ConfigManager, db: Session, dataset_review_context: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: - envs = config_manager.get_environments() - default_env_id = _get_default_environment_id(config_manager) - providers = LLMProviderService(db).get_all_providers() - llm_settings = {} - try: - llm_settings = config_manager.get_config().settings.llm - except Exception: - llm_settings = {} - active_provider = next((p.id for p in providers if p.is_active), None) - fallback_provider = active_provider or (providers[0].id if providers else None) - validation_provider = ( - resolve_bound_provider_id(llm_settings, "dashboard_validation") - or fallback_provider - ) - documentation_provider = ( - resolve_bound_provider_id(llm_settings, "documentation") or fallback_provider - ) - - candidates: list[dict[str, Any]] = [ - { - "operation": "show_capabilities", - "domain": "assistant", - "description": "Show available assistant commands and examples", - "required_entities": [], - "optional_entities": [], - "risk_level": "safe", - "requires_confirmation": False, - }, - { - "operation": "get_task_status", - "domain": "status", - "description": "Get task status by task_id or latest user task", - "required_entities": [], - "optional_entities": ["task_id"], - "risk_level": "safe", - "requires_confirmation": False, - }, - { - "operation": "create_branch", - "domain": "git", - "description": "Create git branch for dashboard by id/slug/title", - "required_entities": ["branch_name"], - "optional_entities": ["dashboard_id", "dashboard_ref"], - "risk_level": "guarded", - "requires_confirmation": False, - }, - { - "operation": "commit_changes", - "domain": "git", - "description": "Commit dashboard repository changes by dashboard id/slug/title", - "required_entities": [], - "optional_entities": ["dashboard_id", "dashboard_ref", "message"], - "risk_level": "guarded", - "requires_confirmation": False, - }, - { - "operation": "deploy_dashboard", - "domain": "git", - "description": "Deploy dashboard (id/slug/title) to target environment", - "required_entities": ["environment"], - "optional_entities": ["dashboard_id", "dashboard_ref"], - "risk_level": "guarded", - "requires_confirmation": False, - }, - { - "operation": "execute_migration", - "domain": "migration", - "description": "Run dashboard migration (id/slug/title) between environments. Optional boolean flags: replace_db_config, fix_cross_filters", - "required_entities": ["source_env", "target_env"], - "optional_entities": [ - "dashboard_id", - "dashboard_ref", - "replace_db_config", - "fix_cross_filters", - ], - "risk_level": "guarded", - "requires_confirmation": False, - }, - { - "operation": "run_backup", - "domain": "backup", - "description": "Run backup for environment or specific dashboard by id/slug/title", - "required_entities": ["environment"], - "optional_entities": ["dashboard_id", "dashboard_ref"], - "risk_level": "guarded", - "requires_confirmation": False, - }, - { - "operation": "run_llm_validation", - "domain": "llm", - "description": "Run LLM dashboard validation by dashboard id/slug/title", - "required_entities": [], - "optional_entities": ["dashboard_ref", "environment", "provider"], - "defaults": { - "environment": default_env_id, - "provider": validation_provider, - }, - "risk_level": "guarded", - "requires_confirmation": False, - }, - { - "operation": "run_llm_documentation", - "domain": "llm", - "description": "Generate dataset documentation via LLM", - "required_entities": ["dataset_id"], - "optional_entities": ["environment", "provider"], - "defaults": { - "environment": default_env_id, - "provider": documentation_provider, - }, - "risk_level": "guarded", - "requires_confirmation": False, - }, - { - "operation": "get_health_summary", - "domain": "health", - "description": "Get summary of dashboard health and failing validations", - "required_entities": [], - "optional_entities": ["environment"], - "risk_level": "safe", - "requires_confirmation": False, - }, - ] - - available: list[dict[str, Any]] = [] - for tool in candidates: - checks = INTENT_PERMISSION_CHECKS.get(tool["operation"], []) - if checks and not _has_any_permission(current_user, checks): - continue - available.append(tool) + """Build tool catalog from the central registry, with optional dataset-review tools.""" + available = get_catalog(current_user, config_manager, db) + # Dataset review tools are session-scoped — only available when a review is active. if dataset_review_context is not None: + from ._schemas import INTENT_PERMISSION_CHECKS + dataset_tools: list[dict[str, Any]] = [ { "operation": "dataset_review_answer_context", diff --git a/backend/src/api/routes/assistant/_llm_planner_intent.py b/backend/src/api/routes/assistant/_llm_planner_intent.py index ddf3df86..fef11760 100644 --- a/backend/src/api/routes/assistant/_llm_planner_intent.py +++ b/backend/src/api/routes/assistant/_llm_planner_intent.py @@ -34,7 +34,7 @@ from ._resolvers import ( _is_production_env, _resolve_provider_id, ) -from ._schemas import INTENT_PERMISSION_CHECKS +from ._tool_registry import get_permission_checks # #region _plan_intent_with_llm [C:2] [TYPE Function] @@ -71,7 +71,7 @@ async def _plan_intent_with_llm( ) system_instruction = ( - "You are a deterministic intent planner for backend tools.\n" + "You are a helpful assistant that understands backend tools.\n" "Choose exactly one operation from available_tools or return clarify.\n" "Output strict JSON object:\n" "{" @@ -85,6 +85,8 @@ async def _plan_intent_with_llm( "Rules:\n" "- Use only operation names from available_tools.\n" '- If input is ambiguous, operation must be "clarify" with low confidence.\n' + ' Include "clarification" field with a specific question in Russian ' + "asking what exactly is missing or unclear.\n" "- If dashboard is provided as name/slug (e.g., COVID), put it into entities.dashboard_ref.\n" "- Keep entities minimal and factual.\n" ) @@ -115,6 +117,7 @@ async def _plan_intent_with_llm( operation = response.get("operation") valid_ops = {tool["operation"] for tool in tools} if operation == "clarify": + clarification = response.get("clarification", "") return { "domain": "unknown", "operation": "clarify", @@ -122,6 +125,7 @@ async def _plan_intent_with_llm( "confidence": float(response.get("confidence", 0.3)), "risk_level": "safe", "requires_confirmation": False, + "clarification": clarification, } if operation not in valid_ops: return None @@ -164,8 +168,9 @@ async def _plan_intent_with_llm( # @POST Returns if authorized; raises HTTPException(403) when denied. def _authorize_intent(intent: dict[str, Any], current_user: User): operation = intent.get("operation") - if operation in INTENT_PERMISSION_CHECKS: - _check_any_permission(current_user, INTENT_PERMISSION_CHECKS[operation]) + checks = get_permission_checks().get(operation) + if checks: + _check_any_permission(current_user, checks) # #endregion _authorize_intent diff --git a/backend/src/api/routes/assistant/_routes.py b/backend/src/api/routes/assistant/_routes.py index 05a90e37..e532105b 100644 --- a/backend/src/api/routes/assistant/_routes.py +++ b/backend/src/api/routes/assistant/_routes.py @@ -37,8 +37,8 @@ from ._dataset_review import ( from ._dispatch import ( _async_confirmation_summary, _clarification_text_for_intent, - _dispatch_intent, ) +from ._tool_registry import dispatch from ._history import ( _append_history, _audit, @@ -51,8 +51,8 @@ from ._history import ( ) from ._llm_planner import _build_tool_catalog from ._llm_planner_intent import _authorize_intent, _plan_intent_with_llm +from ._tool_registry import get_safe_ops from ._schemas import ( - _SAFE_OPS, CONFIRMATIONS, AssistantAction, AssistantMessageRequest, @@ -69,7 +69,7 @@ router = APIRouter(tags=["Assistant"]) # @DATA_CONTRACT Input[AssistantMessageRequest,User,TaskManager,ConfigManager,Session] -> Output[AssistantMessageResponse] # @RELATION DEPENDS_ON -> [_plan_intent_with_llm] # @RELATION DEPENDS_ON -> [_parse_command] -# @RELATION DEPENDS_ON -> [_dispatch_intent] +# @RELATION DEPENDS_ON -> [dispatch] # @RELATION DEPENDS_ON -> [_append_history] # @RELATION DEPENDS_ON -> [_persist_message] # @RELATION DEPENDS_ON -> [_audit] @@ -99,7 +99,12 @@ async def send_message(request: AssistantMessageRequest, current_user: User=Depe intent = dataset_review_intent confidence = float(intent.get('confidence', 0.0)) if intent.get('domain') == 'unknown' or confidence < 0.6: - text = 'Команда неоднозначна. Уточните действие: git / migration / backup / llm / status.' + # Use LLM-generated clarification question when available (from _plan_intent_with_llm), + # otherwise fall back to deterministic text. + text = ( + intent.get('clarification') + or 'Команда неоднозначна. Уточните, что вы хотите сделать.' + ) _append_history(user_id, conversation_id, 'assistant', text, state='needs_clarification') _persist_message(db, user_id, conversation_id, 'assistant', text, state='needs_clarification', metadata={'intent': intent}) audit_payload = {'decision': 'needs_clarification', 'message': request.message, 'intent': intent, 'dataset_review_session_id': request.dataset_review_session_id} @@ -110,7 +115,7 @@ async def send_message(request: AssistantMessageRequest, current_user: User=Depe try: _authorize_intent(intent, current_user) operation = intent.get('operation') - if operation not in _SAFE_OPS: + if operation not in get_safe_ops(): confirmation_id = str(uuid.uuid4()) confirm = ConfirmationRecord(id=confirmation_id, user_id=user_id, conversation_id=conversation_id, intent=intent, dispatch={'intent': intent}, expires_at=datetime.now(UTC) + __import__('datetime').timedelta(minutes=5), created_at=datetime.now(UTC)) CONFIRMATIONS[confirmation_id] = confirm @@ -123,7 +128,7 @@ async def send_message(request: AssistantMessageRequest, current_user: User=Depe _persist_audit(db, user_id, audit_payload, conversation_id) logger.reflect('Belief protocol postcondition checkpoint for send_message') return AssistantMessageResponse(conversation_id=conversation_id, response_id=str(uuid.uuid4()), state='needs_confirmation', text=text, intent=intent, confirmation_id=confirmation_id, actions=[AssistantAction(type='confirm', label='✅ Подтвердить', target=confirmation_id), AssistantAction(type='cancel', label='❌ Отменить', target=confirmation_id)], created_at=datetime.now(UTC)) - text, task_id, actions = await _dispatch_intent(intent, current_user, task_manager, config_manager, db) + text, task_id, actions = await dispatch(intent.get('operation'), intent, current_user, task_manager, config_manager, db) state = 'started' if task_id else 'success' _append_history(user_id, conversation_id, 'assistant', text, state=state, task_id=task_id) _persist_message(db, user_id, conversation_id, 'assistant', text, state=state, task_id=task_id, metadata={'intent': intent, 'dataset_review_context': dataset_review_context, 'actions': [a.model_dump() for a in actions]}) @@ -193,8 +198,8 @@ async def confirm_operation( raise HTTPException(status_code=400, detail="Confirmation expired") intent = record.intent - text, task_id, actions = await _dispatch_intent( - intent, current_user, task_manager, config_manager, db + text, task_id, actions = await dispatch( + intent.get('operation'), intent, current_user, task_manager, config_manager, db ) record.state = "consumed" _update_confirmation_state(db, confirmation_id, "consumed") diff --git a/backend/src/api/routes/assistant/_tool_backup.py b/backend/src/api/routes/assistant/_tool_backup.py new file mode 100644 index 00000000..68598999 --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_backup.py @@ -0,0 +1,100 @@ +# #region AssistantToolBackup [C:3] [TYPE Module] [SEMANTICS assistant, tool, backup] +# @BRIEF Handler for the "run_backup" tool — run backup for environment or specific dashboard. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [TaskManager] + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User + +from ._resolvers import ( + _get_environment_name_by_id, + _resolve_dashboard_id_entity, + _resolve_env_id, +) +from ._schemas import AssistantAction +from ._tool_registry import _check_any_permission, assistant_tool + + +# #region handle_run_backup [C:3] [TYPE Function] +@assistant_tool( + operation="run_backup", + domain="backup", + description="Run backup for environment or specific dashboard by id/slug/title", + required_entities=["environment"], + optional_entities=["dashboard_id", "dashboard_ref"], + risk_level="guarded", + requires_confirmation=False, + permission_checks=[ + ("plugin:superset-backup", "EXECUTE"), + ("plugin:backup", "EXECUTE"), + ], +) +@belief_scope("run_backup") +async def handle_run_backup( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Run backup for environment or specific dashboard.""" + _check_any_permission( + current_user, + [("plugin:superset-backup", "EXECUTE"), ("plugin:backup", "EXECUTE")], + ) + entities = intent.get("entities", {}) + env_token = entities.get("environment") + env_id = _resolve_env_id(env_token, config_manager) + if not env_id: + raise HTTPException(status_code=400, detail="Missing or unknown environment") + params: dict[str, Any] = {"environment_id": env_id} + if entities.get("dashboard_id") or entities.get("dashboard_ref"): + dashboard_id = _resolve_dashboard_id_entity( + entities, config_manager, env_hint=env_token + ) + if not dashboard_id: + raise HTTPException( + status_code=422, detail="Missing dashboard_id/dashboard_ref" + ) + params["dashboard_ids"] = [dashboard_id] + task = await task_manager.create_task( + plugin_id="superset-backup", params=params, user_id=current_user.id + ) + actions: list[AssistantAction] = [ + AssistantAction(type="open_task", label="Open Task", target=task.id), + AssistantAction( + type="open_reports", label="Open Reports", target="/reports" + ), + ] + if entities.get("dashboard_id") or entities.get("dashboard_ref"): + dashboard_id = _resolve_dashboard_id_entity( + entities, config_manager, env_hint=env_token + ) + if dashboard_id: + actions.append( + AssistantAction( + type="open_route", + label=f"Открыть дашборд в {_get_environment_name_by_id(env_id, config_manager)}", + target=f"/dashboards/{dashboard_id}?env_id={env_id}", + ) + ) + actions.append( + AssistantAction( + type="open_diff", label="Показать Diff", target=str(dashboard_id) + ) + ) + return (f"Бэкап запущен. task_id={task.id}", task.id, actions) + + +# #endregion handle_run_backup +# #endregion AssistantToolBackup diff --git a/backend/src/api/routes/assistant/_tool_capabilities.py b/backend/src/api/routes/assistant/_tool_capabilities.py new file mode 100644 index 00000000..ad9f1abe --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_capabilities.py @@ -0,0 +1,67 @@ +# #region AssistantToolCapabilities [C:3] [TYPE Module] [SEMANTICS assistant, tool, capabilities, catalog] +# @BRIEF Handler for the "show_capabilities" tool — lists available assistant commands and examples. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User + +from ._schemas import AssistantAction +from ._tool_registry import assistant_tool, get_catalog + +_HUMAN_LABELS: dict[str, str] = { + "create_branch": "Git: создание ветки", + "commit_changes": "Git: коммит", + "deploy_dashboard": "Git: деплой дашборда", + "execute_migration": "Миграции: запуск переноса", + "run_backup": "Бэкапы: запуск резервного копирования", + "run_llm_validation": "LLM: валидация дашборда", + "run_llm_documentation": "LLM: генерация документации", + "get_task_status": "Статус: проверка задачи", + "get_health_summary": "Здоровье: сводка по дашбордам", +} + + +# #region handle_show_capabilities [C:2] [TYPE Function] +@assistant_tool( + operation="show_capabilities", + domain="assistant", + description="Show available assistant commands and examples", + risk_level="safe", + requires_confirmation=False, +) +@belief_scope("show_capabilities") +async def handle_show_capabilities( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Show available assistant commands and examples.""" + tools_catalog = get_catalog(current_user, config_manager, db) + available = [ + _HUMAN_LABELS[t["operation"]] + for t in tools_catalog + if t["operation"] in _HUMAN_LABELS + ] + if not available: + return ("Сейчас нет доступных для вас операций ассистента.", None, []) + commands = "\n".join(f"- {item}" for item in available) + text = ( + f"Вот что я могу сделать для вас:\n{commands}\n\n" + "Пример: `запусти миграцию с dev на prod для дашборда 42`." + ) + return (text, None, []) + + +# #endregion handle_show_capabilities +# #endregion AssistantToolCapabilities diff --git a/backend/src/api/routes/assistant/_tool_commit.py b/backend/src/api/routes/assistant/_tool_commit.py new file mode 100644 index 00000000..327ba6e4 --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_commit.py @@ -0,0 +1,56 @@ +# #region AssistantToolCommit [C:3] [TYPE Module] [SEMANTICS assistant, tool, git, commit] +# @BRIEF Handler for the "commit_changes" tool — commit dashboard repository changes. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [GitService] + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User + +from ._resolvers import _resolve_dashboard_id_entity +from ._schemas import AssistantAction +from ._tool_registry import _check_any_permission, assistant_tool + +from ._dispatch import _get_git_service + + +# #region handle_commit_changes [C:3] [TYPE Function] +@assistant_tool( + operation="commit_changes", + domain="git", + description="Commit dashboard repository changes by dashboard id/slug/title", + optional_entities=["dashboard_id", "dashboard_ref", "message"], + risk_level="guarded", + requires_confirmation=False, + permission_checks=[("plugin:git", "EXECUTE")], +) +@belief_scope("commit_changes") +async def handle_commit_changes( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Commit dashboard repository changes.""" + _check_any_permission(current_user, [("plugin:git", "EXECUTE")]) + entities = intent.get("entities", {}) + dashboard_id = _resolve_dashboard_id_entity(entities, config_manager) + commit_message = entities.get("message") + if not dashboard_id: + raise HTTPException(status_code=422, detail="Missing dashboard_id/dashboard_ref") + _get_git_service().commit_changes(dashboard_id, commit_message, None) + return ("Коммит выполнен успешно.", None, []) + + +# #endregion handle_commit_changes +# #endregion AssistantToolCommit diff --git a/backend/src/api/routes/assistant/_tool_create_branch.py b/backend/src/api/routes/assistant/_tool_create_branch.py new file mode 100644 index 00000000..82b5caac --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_create_branch.py @@ -0,0 +1,59 @@ +# #region AssistantToolCreateBranch [C:3] [TYPE Module] [SEMANTICS assistant, tool, git, branch] +# @BRIEF Handler for the "create_branch" tool — create git branch for a dashboard. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [GitService] + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User + +from ._resolvers import _resolve_dashboard_id_entity +from ._schemas import AssistantAction +from ._tool_registry import _check_any_permission, assistant_tool + +from ._dispatch import _get_git_service + + +# #region handle_create_branch [C:3] [TYPE Function] +@assistant_tool( + operation="create_branch", + domain="git", + description="Create git branch for dashboard by id/slug/title", + required_entities=["branch_name"], + optional_entities=["dashboard_id", "dashboard_ref"], + risk_level="guarded", + requires_confirmation=False, + permission_checks=[("plugin:git", "EXECUTE")], +) +@belief_scope("create_branch") +async def handle_create_branch( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Create git branch for dashboard by id/slug/title.""" + _check_any_permission(current_user, [("plugin:git", "EXECUTE")]) + entities = intent.get("entities", {}) + dashboard_id = _resolve_dashboard_id_entity(entities, config_manager) + branch_name = entities.get("branch_name") + if not dashboard_id or not branch_name: + raise HTTPException( + status_code=422, detail="Missing dashboard_id/dashboard_ref or branch_name" + ) + _get_git_service().create_branch(dashboard_id, branch_name, "main") + return (f"Ветка `{branch_name}` создана для дашборда {dashboard_id}.", None, []) + + +# #endregion handle_create_branch +# #endregion AssistantToolCreateBranch diff --git a/backend/src/api/routes/assistant/_tool_deploy.py b/backend/src/api/routes/assistant/_tool_deploy.py new file mode 100644 index 00000000..10162a8e --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_deploy.py @@ -0,0 +1,77 @@ +# #region AssistantToolDeploy [C:3] [TYPE Module] [SEMANTICS assistant, tool, git, deploy] +# @BRIEF Handler for the "deploy_dashboard" tool — deploy dashboard to target environment. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [TaskManager] + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User + +from ._resolvers import _resolve_dashboard_id_entity, _resolve_env_id +from ._schemas import AssistantAction +from ._tool_registry import _check_any_permission, assistant_tool + + +# #region handle_deploy_dashboard [C:3] [TYPE Function] +@assistant_tool( + operation="deploy_dashboard", + domain="git", + description="Deploy dashboard (id/slug/title) to target environment", + required_entities=["environment"], + optional_entities=["dashboard_id", "dashboard_ref"], + risk_level="guarded", + requires_confirmation=False, + permission_checks=[("plugin:git", "EXECUTE")], +) +@belief_scope("deploy_dashboard") +async def handle_deploy_dashboard( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Deploy dashboard to target environment.""" + _check_any_permission(current_user, [("plugin:git", "EXECUTE")]) + entities = intent.get("entities", {}) + env_token = entities.get("environment") + env_id = _resolve_env_id(env_token, config_manager) + dashboard_id = _resolve_dashboard_id_entity( + entities, config_manager, env_hint=env_token + ) + if not dashboard_id or not env_id: + raise HTTPException( + status_code=422, detail="Missing dashboard_id/dashboard_ref or environment" + ) + task = await task_manager.create_task( + plugin_id="git-integration", + params={ + "operation": "deploy", + "dashboard_id": dashboard_id, + "environment_id": env_id, + }, + user_id=current_user.id, + ) + return ( + f"Деплой запущен. task_id={task.id}", + task.id, + [ + AssistantAction(type="open_task", label="Open Task", target=task.id), + AssistantAction( + type="open_reports", label="Open Reports", target="/reports" + ), + ], + ) + + +# #endregion handle_deploy_dashboard +# #endregion AssistantToolDeploy diff --git a/backend/src/api/routes/assistant/_tool_health_summary.py b/backend/src/api/routes/assistant/_tool_health_summary.py new file mode 100644 index 00000000..351b9c06 --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_health_summary.py @@ -0,0 +1,81 @@ +# #region AssistantToolHealthSummary [C:3] [TYPE Module] [SEMANTICS assistant, tool, health, summary] +# @BRIEF Handler for the "get_health_summary" tool — get summary of dashboard health. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [HealthService] + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User +from src.services.health_service import HealthService + +from ._resolvers import _get_environment_name_by_id, _resolve_env_id +from ._schemas import AssistantAction +from ._tool_registry import assistant_tool + + +# #region handle_get_health_summary [C:3] [TYPE Function] +@assistant_tool( + operation="get_health_summary", + domain="health", + description="Get summary of dashboard health and failing validations", + optional_entities=["environment"], + risk_level="safe", + requires_confirmation=False, + permission_checks=[("plugin:migration", "READ")], +) +@belief_scope("get_health_summary") +async def handle_get_health_summary( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Get summary of dashboard health and failing validations.""" + entities = intent.get("entities", {}) + env_token = entities.get("environment") + env_id = _resolve_env_id(env_token, config_manager) + service = HealthService(db) + summary = await service.get_health_summary(environment_id=env_id) + env_name = ( + _get_environment_name_by_id(env_id, config_manager) + if env_id + else "всех окружений" + ) + text = ( + f"Сводка здоровья дашбордов для {env_name}:\n" + f"- ✅ Прошли проверку: {summary.pass_count}\n" + f"- ⚠️ С предупреждениями: {summary.warn_count}\n" + f"- ❌ Ошибки валидации: {summary.fail_count}\n" + f"- ❓ Неизвестно: {summary.unknown_count}" + ) + actions: list[AssistantAction] = [ + AssistantAction( + type="open_route", label="Открыть Health Center", target="/dashboards/health" + ) + ] + if summary.fail_count > 0: + text += "\n\nОбнаружены ошибки в следующих дашбордах:" + for item in summary.items: + if item.status == "FAIL": + text += f"\n- {item.dashboard_id} ({item.environment_id}): {item.summary or 'Нет деталей'}" + actions.append( + AssistantAction( + type="open_route", + label=f"Отчет {item.dashboard_id}", + target=f"/reports/llm/{item.task_id}", + ) + ) + return (text, None, actions[:5]) + + +# #endregion handle_get_health_summary +# #endregion AssistantToolHealthSummary diff --git a/backend/src/api/routes/assistant/_tool_list_environments.py b/backend/src/api/routes/assistant/_tool_list_environments.py new file mode 100644 index 00000000..a8d6c76a --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_list_environments.py @@ -0,0 +1,65 @@ +# #region AssistantToolListEnvironments [C:2] [TYPE Module] [SEMANTICS assistant, tool, environments, list] +# @BRIEF Handler for the "list_environments" tool — show available Superset environments. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User + +from ._schemas import AssistantAction +from ._tool_registry import assistant_tool + + +# #region handle_list_environments [C:2] [TYPE Function] +@assistant_tool( + operation="list_environments", + domain="environments", + description="Show available Superset environments with their id, name, and stage (DEV/PREPROD/PROD)", + risk_level="safe", + requires_confirmation=False, +) +@belief_scope("list_environments") +async def handle_list_environments( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Show available Superset environments.""" + envs = config_manager.get_environments() + + if not envs: + return ("Нет доступных окружений.", None, []) + + lines = ["**Доступные окружения:**"] + for env in envs: + stage_icon = {"DEV": "🟢", "PREPROD": "🟡", "PROD": "🔴"} + icon = stage_icon.get(env.stage, "⚪") + prod_tag = " ⚠️ PRODUCTION" if env.is_production else "" + default_tag = " ★ По умолчанию" if env.is_default else "" + lines.append( + f"{icon} **{env.id}** — {env.name}{prod_tag}{default_tag}" + ) + + actions = [ + AssistantAction( + type="open_route", + label="Открыть настройки окружений", + target="/environments", + ) + ] + + return ("\n".join(lines), None, actions) + + +# #endregion handle_list_environments +# #endregion AssistantToolListEnvironments diff --git a/backend/src/api/routes/assistant/_tool_llm.py b/backend/src/api/routes/assistant/_tool_llm.py new file mode 100644 index 00000000..36148e53 --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_llm.py @@ -0,0 +1,127 @@ +# #region AssistantToolLlm [C:2] [TYPE Module] [SEMANTICS assistant, tool, llm, providers, status] +# @BRIEF Handlers for LLM operations — list providers and check LLM status. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [LLMProviderService] + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User +from src.services.llm_provider import LLMProviderService + +from ._schemas import AssistantAction +from ._tool_registry import assistant_tool + + +# #region handle_list_llm_providers [C:2] [TYPE Function] +@assistant_tool( + operation="list_llm_providers", + domain="llm", + description="List configured LLM providers with their type, model, and active status", + risk_level="safe", + requires_confirmation=False, +) +@belief_scope("list_llm_providers") +async def handle_list_llm_providers( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """List configured LLM providers.""" + service = LLMProviderService(db) + providers = service.get_all_providers() + + if not providers: + return ("LLM провайдеры не настроены.", None, []) + + lines = ["**🤖 LLM провайдеры:**"] + for p in providers: + icon = "🟢" if p.is_active else "⚪" + model = p.default_model or "модель не указана" + lines.append( + f"{icon} **{p.name}** — {p.provider_type}" + f"\n └ Модель: {model}" + ) + + return ( + "\n".join(lines), + None, + [ + AssistantAction( + type="open_route", + label="Настройки LLM", + target="/settings/llm", + ) + ], + ) + + +# #endregion handle_list_llm_providers + + +# #region handle_get_llm_status [C:2] [TYPE Function] +@assistant_tool( + operation="get_llm_status", + domain="llm", + description="Check if LLM is configured and ready for dashboard validation and documentation", + risk_level="safe", + requires_confirmation=False, +) +@belief_scope("get_llm_status") +async def handle_get_llm_status( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Check LLM configuration and readiness status.""" + service = LLMProviderService(db) + providers = service.get_all_providers() + active_provider = next((p for p in providers if p.is_active), None) + + lines = ["**🤖 LLM Статус:**"] + + if not providers: + lines.append("❌ Провайдеры не настроены.") + return ("\n".join(lines), None, []) + + lines.append(f"📊 Всего провайдеров: {len(providers)}") + lines.append(f"🟢 Активных: {len([p for p in providers if p.is_active])}") + lines.append(f"⚪ Неактивных: {len([p for p in providers if not p.is_active])}") + + if active_provider: + api_key = service.get_decrypted_api_key(active_provider.id) + from src.api.routes.llm import _is_valid_runtime_api_key + + key_valid = _is_valid_runtime_api_key(api_key) + lines.append(f"\n**Активный провайдер:** {active_provider.name}") + lines.append(f" └ Тип: {active_provider.provider_type}") + lines.append(f" └ Модель: {active_provider.default_model or 'не указана'}") + lines.append(f" └ API ключ: {'✅ валидный' if key_valid else '❌ невалидный/отсутствует'}") + lines.append(f" └ Статус: {'✅ готов' if key_valid else '❌ настроен не полностью'}") + + return ( + "\n".join(lines), + None, + [ + AssistantAction( + type="open_route", + label="Настройки LLM", + target="/settings/llm", + ) + ], + ) + + +# #endregion handle_get_llm_status +# #endregion AssistantToolLlm diff --git a/backend/src/api/routes/assistant/_tool_llm_documentation.py b/backend/src/api/routes/assistant/_tool_llm_documentation.py new file mode 100644 index 00000000..b1a0b989 --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_llm_documentation.py @@ -0,0 +1,80 @@ +# #region AssistantToolLlmDocumentation [C:3] [TYPE Module] [SEMANTICS assistant, tool, llm, documentation] +# @BRIEF Handler for the "run_llm_documentation" tool — generate dataset documentation via LLM. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [TaskManager] + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User + +from ._resolvers import _resolve_env_id, _resolve_provider_id +from ._schemas import AssistantAction +from ._tool_registry import _check_any_permission, assistant_tool + + +# #region handle_run_llm_documentation [C:3] [TYPE Function] +@assistant_tool( + operation="run_llm_documentation", + domain="llm", + description="Generate dataset documentation via LLM", + required_entities=["dataset_id"], + optional_entities=["environment", "provider"], + risk_level="guarded", + requires_confirmation=False, + permission_checks=[("plugin:llm_documentation", "EXECUTE")], +) +@belief_scope("run_llm_documentation") +async def handle_run_llm_documentation( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Generate dataset documentation via LLM.""" + _check_any_permission(current_user, [("plugin:llm_documentation", "EXECUTE")]) + entities = intent.get("entities", {}) + dataset_id = entities.get("dataset_id") + env_id = _resolve_env_id(entities.get("environment"), config_manager) + provider_id = _resolve_provider_id( + entities.get("provider"), + db, + config_manager=config_manager, + task_key="documentation", + ) + if not dataset_id or not env_id or (not provider_id): + raise HTTPException( + status_code=400, detail="Missing dataset_id/environment/provider" + ) + task = await task_manager.create_task( + plugin_id="llm_documentation", + params={ + "dataset_id": str(dataset_id), + "environment_id": env_id, + "provider_id": provider_id, + }, + user_id=current_user.id, + ) + return ( + f"Генерация документации запущена. task_id={task.id}", + task.id, + [ + AssistantAction(type="open_task", label="Open Task", target=task.id), + AssistantAction( + type="open_reports", label="Open Reports", target="/reports" + ), + ], + ) + + +# #endregion handle_run_llm_documentation +# #endregion AssistantToolLlmDocumentation diff --git a/backend/src/api/routes/assistant/_tool_llm_validation.py b/backend/src/api/routes/assistant/_tool_llm_validation.py new file mode 100644 index 00000000..808bb96c --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_llm_validation.py @@ -0,0 +1,97 @@ +# #region AssistantToolLlmValidation [C:3] [TYPE Module] [SEMANTICS assistant, tool, llm, validation] +# @BRIEF Handler for the "run_llm_validation" tool — run LLM dashboard validation. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [LLMProviderService] +# @RELATION DEPENDS_ON -> [TaskManager] + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User +from src.services.llm_provider import LLMProviderService + +from ._resolvers import ( + _resolve_dashboard_id_entity, + _resolve_env_id, + _resolve_provider_id, +) +from ._schemas import AssistantAction +from ._tool_registry import _check_any_permission, assistant_tool + + +# #region handle_run_llm_validation [C:3] [TYPE Function] +@assistant_tool( + operation="run_llm_validation", + domain="llm", + description="Run LLM dashboard validation by dashboard id/slug/title", + optional_entities=["dashboard_ref", "environment", "provider"], + risk_level="guarded", + requires_confirmation=False, + permission_checks=[("plugin:llm_dashboard_validation", "EXECUTE")], +) +@belief_scope("run_llm_validation") +async def handle_run_llm_validation( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Run LLM dashboard validation.""" + _check_any_permission(current_user, [("plugin:llm_dashboard_validation", "EXECUTE")]) + entities = intent.get("entities", {}) + env_token = entities.get("environment") + env_id = _resolve_env_id(env_token, config_manager) or _resolve_env_id( + None, config_manager + ) + dashboard_id = _resolve_dashboard_id_entity( + entities, config_manager, env_hint=env_token + ) + provider_id = _resolve_provider_id( + entities.get("provider"), + db, + config_manager=config_manager, + task_key="dashboard_validation", + ) + if not dashboard_id or not env_id or (not provider_id): + raise HTTPException( + status_code=422, + detail="Missing dashboard_id/environment/provider. Укажите ID/slug дашборда или окружение.", + ) + provider = LLMProviderService(db).get_provider(provider_id) + if not provider or not bool(provider.is_multimodal): + raise HTTPException( + status_code=422, + detail="Selected provider model is not multimodal for dashboard validation. Выберите мультимодальную модель (например, gpt-4o).", + ) + task = await task_manager.create_task( + plugin_id="llm_dashboard_validation", + params={ + "dashboard_id": str(dashboard_id), + "environment_id": env_id, + "provider_id": provider_id, + }, + user_id=current_user.id, + ) + return ( + f"LLM-валидация запущена. task_id={task.id}", + task.id, + [ + AssistantAction(type="open_task", label="Open Task", target=task.id), + AssistantAction( + type="open_reports", label="Open Reports", target="/reports" + ), + ], + ) + + +# #endregion handle_run_llm_validation +# #endregion AssistantToolLlmValidation diff --git a/backend/src/api/routes/assistant/_tool_maintenance.py b/backend/src/api/routes/assistant/_tool_maintenance.py new file mode 100644 index 00000000..250db5da --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_maintenance.py @@ -0,0 +1,351 @@ +# #region AssistantToolMaintenance [C:3] [TYPE Module] [SEMANTICS assistant, tool, maintenance, events] +# @BRIEF Handlers for maintenance operations — list events, start maintenance, end maintenance. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [MaintenanceModels] + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User +from src.models.maintenance import ( + MaintenanceEvent, + MaintenanceEventStatus, + MaintenanceDashboardState, + MaintenanceDashboardStateStatus, +) + +from ._schemas import AssistantAction +from ._tool_registry import _check_any_permission, assistant_tool + + +# #region handle_list_maintenance_events [C:2] [TYPE Function] +@assistant_tool( + operation="list_maintenance_events", + domain="maintenance", + description="List active and recent maintenance events (maintenance windows) with affected tables and status", + risk_level="safe", + requires_confirmation=False, + permission_checks=[("maintenance", "READ")], +) +@belief_scope("list_maintenance_events") +async def handle_list_maintenance_events( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """List active and recent maintenance events.""" + _check_any_permission(current_user, [("maintenance", "READ")]) + + # Auto-expiry: end events past their end_time + now = datetime.now(UTC) + expired = ( + db.query(MaintenanceEvent) + .filter( + MaintenanceEvent.status.in_([ + MaintenanceEventStatus.ACTIVE, + MaintenanceEventStatus.PARTIAL, + MaintenanceEventStatus.ENDING, + ]), + MaintenanceEvent.end_time.isnot(None), + MaintenanceEvent.end_time < now, + ) + .all() + ) + for ev in expired: + task = await task_manager.create_task( + plugin_id="maintenance_banner_apply", + params={ + "operation": "end", + "event_id": ev.id, + "environment_id": ev.environment_id, + "user": "system", + }, + ) + + # Active events + active_statuses = [ + MaintenanceEventStatus.ACTIVE, + MaintenanceEventStatus.PARTIAL, + ] + active_events = ( + db.query(MaintenanceEvent) + .filter(MaintenanceEvent.status.in_(active_statuses)) + .order_by(MaintenanceEvent.created_at.desc()) + .all() + ) + + # Completed events (last 20) + completed_events = ( + db.query(MaintenanceEvent) + .filter( + MaintenanceEvent.status.in_([ + MaintenanceEventStatus.COMPLETED, + MaintenanceEventStatus.FAILED, + ]) + ) + .order_by(MaintenanceEvent.created_at.desc()) + .limit(20) + .all() + ) + + lines = [] + + if active_events: + lines.append("**🟡 Активные события обслуживания:**") + for ev in active_events: + affected = ( + db.query(MaintenanceDashboardState) + .filter( + MaintenanceDashboardState.event_id == ev.id, + MaintenanceDashboardState.status.in_([ + MaintenanceDashboardStateStatus.ACTIVE, + MaintenanceDashboardStateStatus.PENDING_APPLY, + ]), + ) + .count() + ) + status_icon = "🟢" if ev.status == MaintenanceEventStatus.ACTIVE else "🟡" + tables = ", ".join(ev.tables[:5]) if ev.tables else "все" + lines.append( + f"{status_icon} **{ev.id[:8]}** — {tables}" + f" (дашбордов: {affected})" + ) + lines.append("") + + if completed_events: + lines.append("**✅ Завершённые события:**") + for ev in completed_events: + icon = "✅" if ev.status == MaintenanceEventStatus.COMPLETED else "❌" + tables = ", ".join(ev.tables[:3]) if ev.tables else "все" + lines.append(f"{icon} {ev.id[:8]} — {tables}") + lines.append("") + + if not active_events and not completed_events: + lines.append("Нет событий обслуживания.") + + return ( + "\n".join(lines).strip(), + None, + [ + AssistantAction( + type="open_route", + label="Открыть Maintenance", + target="/maintenance", + ) + ], + ) + + +# #endregion handle_list_maintenance_events + + +# #region handle_start_maintenance [C:3] [TYPE Function] +@assistant_tool( + operation="start_maintenance", + domain="maintenance", + description="Start a maintenance window for specified tables with start/end time and optional message", + required_entities=["tables", "start_time"], + optional_entities=["end_time", "message"], + risk_level="guarded", + requires_confirmation=True, + permission_checks=[("maintenance", "WRITE")], +) +@belief_scope("start_maintenance") +async def handle_start_maintenance( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Start a maintenance window.""" + _check_any_permission(current_user, [("maintenance", "WRITE")]) + entities = intent.get("entities", {}) + tables = entities.get("tables", []) + start_time_str = entities.get("start_time") + end_time_str = entities.get("end_time") + message = entities.get("message") + + if not tables or not start_time_str: + raise HTTPException( + status_code=422, + detail="Missing tables or start_time. Укажите таблицы и время начала.", + ) + + if isinstance(tables, str): + tables = [t.strip() for t in tables.split(",")] + + # Parse times + try: + start_time = datetime.fromisoformat(start_time_str) + except (ValueError, TypeError): + raise HTTPException( + status_code=422, + detail="Invalid start_time format. Используйте ISO формат (YYYY-MM-DDTHH:MM).", + ) + + end_time = None + if end_time_str: + try: + end_time = datetime.fromisoformat(end_time_str) + except (ValueError, TypeError): + raise HTTPException( + status_code=422, + detail="Invalid end_time format. Используйте ISO формат (YYYY-MM-DDTHH:MM).", + ) + + # Validate tables list + if len(tables) > 100: + raise HTTPException(status_code=422, detail="Maximum 100 tables allowed") + + # Schedule maintenance via task manager + task = await task_manager.create_task( + plugin_id="maintenance_banner_apply", + params={ + "operation": "start", + "tables": tables, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat() if end_time else None, + "message": message or "", + "user_id": current_user.id, + }, + ) + + table_list = ", ".join(tables[:5]) + if len(tables) > 5: + table_list += f" и ещё {len(tables) - 5}" + + text = ( + f"🟡 Обслуживание запущено. task_id={task.id}\n" + f"Таблицы: {table_list}\n" + f"Начало: {start_time.isoformat()}" + ) + if end_time: + text += f"\nОкончание: {end_time.isoformat()}" + if message: + text += f"\nСообщение: {message}" + + return ( + text, + task.id, + [ + AssistantAction(type="open_task", label="Open Task", target=task.id), + AssistantAction( + type="open_route", + label="Открыть Maintenance", + target="/maintenance", + ), + ], + ) + + +# #endregion handle_start_maintenance + + +# #region handle_end_maintenance [C:2] [TYPE Function] +@assistant_tool( + operation="end_maintenance", + domain="maintenance", + description="End a specific maintenance event by event_id or end all active maintenance events", + optional_entities=["event_id"], + risk_level="guarded", + requires_confirmation=True, + permission_checks=[("maintenance", "WRITE")], +) +@belief_scope("end_maintenance") +async def handle_end_maintenance( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """End a specific maintenance event or all active events.""" + _check_any_permission(current_user, [("maintenance", "WRITE")]) + entities = intent.get("entities", {}) + event_id = entities.get("event_id") + + if event_id: + # End specific event + event = ( + db.query(MaintenanceEvent) + .filter(MaintenanceEvent.id == event_id) + .first() + ) + if not event: + raise HTTPException( + status_code=404, + detail=f"Событие обслуживания {event_id} не найдено.", + ) + + task = await task_manager.create_task( + plugin_id="maintenance_banner_apply", + params={ + "operation": "end", + "event_id": event.id, + "environment_id": event.environment_id, + "user": current_user.id, + }, + ) + return ( + f"✅ Завершение обслуживания {event_id} запущено. task_id={task.id}", + task.id, + [ + AssistantAction(type="open_task", label="Open Task", target=task.id), + ], + ) + + # End all active events + active_events = ( + db.query(MaintenanceEvent) + .filter( + MaintenanceEvent.status.in_([ + MaintenanceEventStatus.ACTIVE, + MaintenanceEventStatus.PARTIAL, + ]) + ) + .all() + ) + + if not active_events: + return ("Нет активных событий обслуживания.", None, []) + + tasks = [] + for event in active_events: + task = await task_manager.create_task( + plugin_id="maintenance_banner_apply", + params={ + "operation": "end", + "event_id": event.id, + "environment_id": event.environment_id, + "user": current_user.id, + }, + ) + tasks.append(task) + + return ( + f"✅ Завершение {len(tasks)} событий обслуживания запущено.", + tasks[0].id if tasks else None, + [ + AssistantAction( + type="open_route", + label="Открыть Maintenance", + target="/maintenance", + ), + ], + ) + + +# #endregion handle_end_maintenance +# #endregion AssistantToolMaintenance diff --git a/backend/src/api/routes/assistant/_tool_migration.py b/backend/src/api/routes/assistant/_tool_migration.py new file mode 100644 index 00000000..d49577c7 --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_migration.py @@ -0,0 +1,118 @@ +# #region AssistantToolMigration [C:4] [TYPE Module] [SEMANTICS assistant, tool, migration, execute] +# @BRIEF Handler for the "execute_migration" tool — run dashboard migration between environments. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [TaskManager] + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User + +from ._history import _coerce_query_bool +from ._resolvers import ( + _get_environment_name_by_id, + _resolve_dashboard_id_entity, + _resolve_env_id, +) +from ._schemas import AssistantAction +from ._tool_registry import _check_any_permission, assistant_tool + + +# #region handle_execute_migration [C:4] [TYPE Function] +@assistant_tool( + operation="execute_migration", + domain="migration", + description=( + "Run dashboard migration (id/slug/title) between environments. " + "Optional boolean flags: replace_db_config, fix_cross_filters" + ), + required_entities=["source_env", "target_env"], + optional_entities=[ + "dashboard_id", + "dashboard_ref", + "replace_db_config", + "fix_cross_filters", + ], + risk_level="guarded", + requires_confirmation=False, + permission_checks=[ + ("plugin:migration", "EXECUTE"), + ("plugin:superset-migration", "EXECUTE"), + ], +) +@belief_scope("execute_migration") +async def handle_execute_migration( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Run dashboard migration between environments.""" + _check_any_permission( + current_user, + [("plugin:migration", "EXECUTE"), ("plugin:superset-migration", "EXECUTE")], + ) + entities = intent.get("entities", {}) + src_token = entities.get("source_env") + dashboard_ref = entities.get("dashboard_ref") + dashboard_id = _resolve_dashboard_id_entity( + entities, config_manager, env_hint=src_token + ) + src = _resolve_env_id(src_token, config_manager) + tgt = _resolve_env_id(entities.get("target_env"), config_manager) + if not src or not tgt: + raise HTTPException(status_code=422, detail="Missing source_env/target_env") + if not dashboard_id and (not dashboard_ref): + raise HTTPException(status_code=422, detail="Missing dashboard_id/dashboard_ref") + migration_params: dict[str, Any] = { + "source_env_id": src, + "target_env_id": tgt, + "replace_db_config": _coerce_query_bool( + entities.get("replace_db_config", False) + ), + "fix_cross_filters": _coerce_query_bool( + entities.get("fix_cross_filters", True) + ), + } + if dashboard_id: + migration_params["selected_ids"] = [dashboard_id] + else: + migration_params["dashboard_regex"] = str(dashboard_ref) + task = await task_manager.create_task( + plugin_id="superset-migration", + params=migration_params, + user_id=current_user.id, + ) + actions: list[AssistantAction] = [ + AssistantAction(type="open_task", label="Open Task", target=task.id), + AssistantAction( + type="open_reports", label="Open Reports", target="/reports" + ), + ] + if dashboard_id: + actions.append( + AssistantAction( + type="open_route", + label=f"Открыть дашборд в {_get_environment_name_by_id(tgt, config_manager)}", + target=f"/dashboards/{dashboard_id}?env_id={tgt}", + ) + ) + actions.append( + AssistantAction( + type="open_diff", label="Показать Diff", target=str(dashboard_id) + ) + ) + return (f"Миграция запущена. task_id={task.id}", task.id, actions) + + +# #endregion handle_execute_migration +# #endregion AssistantToolMigration diff --git a/backend/src/api/routes/assistant/_tool_registry.py b/backend/src/api/routes/assistant/_tool_registry.py new file mode 100644 index 00000000..8681cc9f --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_registry.py @@ -0,0 +1,268 @@ +# #region AssistantToolRegistry [C:4] [TYPE Module] [SEMANTICS assistant, registry, tool, dispatch, catalog] +# @BRIEF Central `@assistant_tool` decorator-based registry: auto-generates LLM catalog, dispatches operations, +# and enforces permissions. Replaces hand-maintained `_build_tool_catalog()`, `_dispatch_intent()`, +# `INTENT_PERMISSION_CHECKS`, and `_SAFE_OPS` to eliminate cross-file desynchronisation. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantSchemas] +# @RELATION DEPENDS_ON -> [AssistantLlmPlanner] +# @RELATION DISPATCHES -> [AssistantToolCapabilities] +# @RELATION DISPATCHES -> [AssistantToolTaskStatus] +# @RELATION DISPATCHES -> [AssistantToolCreateBranch] +# @RELATION DISPATCHES -> [AssistantToolCommit] +# @RELATION DISPATCHES -> [AssistantToolDeploy] +# @RELATION DISPATCHES -> [AssistantToolMigration] +# @RELATION DISPATCHES -> [AssistantToolBackup] +# @RELATION DISPATCHES -> [AssistantToolLlmValidation] +# @RELATION DISPATCHES -> [AssistantToolLlmDocumentation] +# @RELATION DISPATCHES -> [AssistantToolHealthSummary] +# @DATA_CONTRACT Handler[intent, current_user, task_manager, config_manager, db] -> (text: str, task_id: str | None, actions: list[AssistantAction]) +# @INVARIANT Every registered tool has exactly one handler. Unsupported operations raise HTTPException(400). +# @INVARIANT Permission checks are mandatory metadata — not an afterthought. + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Coroutine + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope +from src.core.task_manager import TaskManager +from src.dependencies import has_permission +from src.schemas.auth import User + +from ._schemas import AssistantAction + +# Type alias for the handler signature +ToolHandler = Callable[ + ..., + Coroutine[Any, Any, tuple[str, str | None, list[AssistantAction]]], +] + + +# #region AssistantTool [C:1] [TYPE Class] +@dataclass +class AssistantTool: + """Registered assistant tool metadata + handler.""" + + operation: str + domain: str + description: str + handler: ToolHandler + required_entities: list[str] = field(default_factory=list) + optional_entities: list[str] = field(default_factory=list) + risk_level: str = "safe" + requires_confirmation: bool = False + permission_checks: list[tuple[str, str]] = field(default_factory=list) + defaults: dict[str, Any] | None = None + + +# #endregion AssistantTool + + +# #region _tools [C:1] [TYPE Constant] +# @BRIEF Central tool registry: operation_name -> AssistantTool. +_tools: dict[str, AssistantTool] = {} +# #endregion _tools + + +# #region assistant_tool [C:2] [TYPE Function] +# @BRIEF Decorator that registers a function as an assistant tool handler. +# @POST Tool is registered in `_tools` by operation name. Handler gets `__assistant_tool__` attr. +# @SIDE_EFFECT Mutates the module-level `_tools` dict. +def assistant_tool( + *, + operation: str, + domain: str, + description: str, + required_entities: list[str] | None = None, + optional_entities: list[str] | None = None, + risk_level: str = "safe", + requires_confirmation: bool = False, + permission_checks: list[tuple[str, str]] | None = None, + defaults: dict[str, Any] | None = None, +): + def decorator(func): + tool = AssistantTool( + operation=operation, + domain=domain, + description=description, + handler=func, + required_entities=required_entities or [], + optional_entities=optional_entities or [], + risk_level=risk_level, + requires_confirmation=requires_confirmation, + permission_checks=permission_checks or [], + defaults=defaults, + ) + _tools[operation] = tool + func.__assistant_tool__ = tool # type: ignore[attr-defined] + return func + + return decorator + + +# #endregion assistant_tool + + +# ── Permission helpers ───────────────────────────────────────────────────── + + +# #region _check_any_permission [C:2] [TYPE Function] +# @BRIEF Validate user against alternative permission checks (logical OR). +# @PRE checks list contains resource-action tuples. +# @POST Returns on first successful permission; raises 403-like HTTPException. +def _check_any_permission(current_user: User, checks: list[tuple[str, str]]): + errors: list[HTTPException] = [] + for resource, action in checks: + try: + has_permission(resource, action)(current_user) + return + except HTTPException as exc: + errors.append(exc) + + raise ( + errors[-1] + if errors + else HTTPException(status_code=403, detail="Permission denied") + ) + + +# #endregion _check_any_permission + + +# #region _has_any_permission [C:2] [TYPE Function] +# @BRIEF Boolean variant of _check_any_permission. +# @POST Returns True when at least one permission check passes. +def _has_any_permission(current_user: User, checks: list[tuple[str, str]]) -> bool: + try: + _check_any_permission(current_user, checks) + return True + except HTTPException: + return False + + +# #endregion _has_any_permission + + +# ── Derived lists (replaces _SAFE_OPS, INTENT_PERMISSION_CHECKS) ──────────── + + +# #region get_safe_ops [C:1] [TYPE Function] [SEMANTICS safelist,operations] +# @BRIEF Return set of operation names with risk_level == 'safe'. +# @POST Includes registered tool safe-ops plus known non-registry safe operations +# (e.g. dataset_review_answer_context) that bypass the confirmation gate. +def get_safe_ops() -> set[str]: + """Return set of operation names with risk_level == 'safe'. + + Includes registered tool safe-ops plus known non-registry safe operations + that bypass the confirmation gate. + """ + ops = {op for op, t in _tools.items() if t.risk_level == "safe"} + # dataset_review_answer_context is not registered via @assistant_tool + # (it's dispatched via special-case path) but is read-only / safe. + ops.add("dataset_review_answer_context") + return ops + + +# #endregion get_safe_ops + + +# #region get_permission_checks [C:1] [TYPE Function] [SEMANTICS permissions,authorization] +# @BRIEF Return dict of operation_name -> permission_checks list for all registered tools. +# @POST Returns mapping used by _authorize_intent and catalog filtering. +def get_permission_checks() -> dict[str, list[tuple[str, str]]]: + """Return dict of operation_name -> permission_checks list.""" + result: dict[str, list[tuple[str, str]]] = {} + for op, t in _tools.items(): + if t.permission_checks: + result[op] = t.permission_checks + # Also include dataset_review operations that route via special-case dispatch + from ._schemas import _DATASET_REVIEW_OPS, INTENT_PERMISSION_CHECKS + for op in _DATASET_REVIEW_OPS: + if op in INTENT_PERMISSION_CHECKS: + result[op] = INTENT_PERMISSION_CHECKS[op] + if "dataset_review_answer_context" in INTENT_PERMISSION_CHECKS: + result["dataset_review_answer_context"] = INTENT_PERMISSION_CHECKS["dataset_review_answer_context"] + return result + + +# #endregion get_permission_checks + + +# ── Catalog generation ───────────────────────────────────────────────────── + + +# #region get_catalog [C:2] [TYPE Function] +# @BRIEF Build LLM tool catalog from registry, filtered by user permissions. +# @POST Returns list of dicts suitable for LLM planner prompt. +def get_catalog( + current_user: User, + config_manager: ConfigManager, + db: Session, +) -> list[dict[str, Any]]: + """Build LLM tool catalog from registry, filtered by user permissions.""" + result: list[dict[str, Any]] = [] + for op, tool in sorted(_tools.items(), key=lambda x: x[0]): + if tool.permission_checks and not _has_any_permission( + current_user, tool.permission_checks + ): + continue + entry: dict[str, Any] = { + "operation": op, + "domain": tool.domain, + "description": tool.description, + "required_entities": tool.required_entities, + "optional_entities": tool.optional_entities, + "risk_level": tool.risk_level, + "requires_confirmation": tool.requires_confirmation, + } + if tool.defaults: + entry["defaults"] = tool.defaults + result.append(entry) + return result + + +# #endregion get_catalog + + +# ── Dispatch ──────────────────────────────────────────────────────────────── + + +# #region dispatch [C:2] [TYPE Function] +# @BRIEF Look up operation in registry and call its handler. +# @PRE operation is a known registered tool. +# @POST Returns (text, task_id, actions) from the handler. +async def dispatch( + operation: str, + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Look up operation in registry and call its handler.""" + with belief_scope("tool_registry.dispatch"): + from ._dataset_review_dispatch import _dispatch_dataset_review_intent + from ._schemas import _DATASET_REVIEW_OPS + + # Special case: dataset review operations are dispatched through + # a dedicated sub-dispatcher to isolate session-scoped logic. + if operation in _DATASET_REVIEW_OPS or operation == "dataset_review_answer_context": + return await _dispatch_dataset_review_intent( + intent, current_user, config_manager, db + ) + + tool = _tools.get(operation) + if not tool: + raise HTTPException(status_code=400, detail=f"Unsupported operation: {operation}") + + return await tool.handler(intent, current_user, task_manager, config_manager, db) + + +# #endregion dispatch + + +# #endregion AssistantToolRegistry diff --git a/backend/src/api/routes/assistant/_tool_search_dashboards.py b/backend/src/api/routes/assistant/_tool_search_dashboards.py new file mode 100644 index 00000000..ce42b800 --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_search_dashboards.py @@ -0,0 +1,120 @@ +# #region AssistantToolSearchDashboards [C:3] [TYPE Module] [SEMANTICS assistant, tool, dashboards, search] +# @BRIEF Handler for the "search_dashboards" tool — search and list dashboards from an environment. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [SupersetClient] + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.superset_client import SupersetClient +from src.core.task_manager import TaskManager +from src.schemas.auth import User + +from ._resolvers import _resolve_env_id +from ._schemas import AssistantAction +from ._tool_registry import assistant_tool + + +# #region handle_search_dashboards [C:3] [TYPE Function] +@assistant_tool( + operation="search_dashboards", + domain="dashboards", + description="Search and list dashboards by name/id from an environment. Shows id, title, and URL for each dashboard.", + optional_entities=["environment", "search", "page", "page_size"], + risk_level="safe", + requires_confirmation=False, +) +@belief_scope("search_dashboards") +async def handle_search_dashboards( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Search and list dashboards from an environment.""" + entities = intent.get("entities", {}) + env_token = entities.get("environment") + search = entities.get("search") + page = int(entities.get("page", 1)) + page_size = min(int(entities.get("page_size", 20)), 100) + + env_id = _resolve_env_id(env_token, config_manager) + + if not env_id: + # Show available environments if none specified + envs = config_manager.get_environments() + if not envs: + return ("Нет доступных окружений.", None, []) + env_list = "\n".join(f"- {e.id} ({e.name})" for e in envs) + return ( + f"Укажите окружение. Доступные:\n{env_list}\n\n" + f"Пример: `покажи дашборды из окружения dev`", + None, + [], + ) + + env_map = {e.id: e for e in config_manager.get_environments()} + env = env_map.get(env_id) + if not env: + return (f"Окружение {env_id} не найдено.", None, []) + + client = SupersetClient(env) + all_dashboards = client.get_dashboards_summary(require_slug=True) + + # Filter by search term + if search: + search_lower = search.lower() + filtered = [ + d + for d in all_dashboards + if search_lower in str(d.get("id", "")) + or search_lower in str(d.get("title", "")).lower() + or search_lower in str(d.get("slug", "")).lower() + ] + else: + filtered = list(all_dashboards) + + total = len(filtered) + start = (page - 1) * page_size + page_items = filtered[start : start + page_size] + + if not page_items: + if search: + return ( + f"Дашборды по запросу «{search}» в окружении {env.name} не найдены.", + None, + [], + ) + return (f"В окружении {env.name} нет дашбордов.", None, []) + + lines = [f"📊 Дашборды в окружении **{env.name}** ({total} всего):"] + for d in page_items: + title = d.get("title", "Без названия") + did = d.get("id", "?") + slug = d.get("slug", "") + slug_str = f" ({slug})" if slug else "" + lines.append(f"- #{did} {title}{slug_str}") + + if start + page_size < total: + lines.append(f"\n... и ещё {total - start - page_size}") + + actions = [ + AssistantAction( + type="open_route", + label=f"Открыть дашборды в {env.name}", + target=f"/dashboards?env_id={env_id}", + ) + ] + + return ("\n".join(lines), None, actions) + + +# #endregion handle_search_dashboards +# #endregion AssistantToolSearchDashboards diff --git a/backend/src/api/routes/assistant/_tool_task_status.py b/backend/src/api/routes/assistant/_tool_task_status.py new file mode 100644 index 00000000..ea8845b6 --- /dev/null +++ b/backend/src/api/routes/assistant/_tool_task_status.py @@ -0,0 +1,79 @@ +# #region AssistantToolTaskStatus [C:3] [TYPE Module] [SEMANTICS assistant, tool, status, task] +# @BRIEF Handler for the "get_task_status" tool — retrieve task status by id or latest user task. +# @LAYER API +# @RELATION DEPENDS_ON -> [AssistantToolRegistry] +# @RELATION DEPENDS_ON -> [TaskManager] + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from src.core.config_manager import ConfigManager +from src.core.logger import belief_scope, logger +from src.core.task_manager import TaskManager +from src.schemas.auth import User + +from ._resolvers import _build_task_observability_summary, _extract_result_deep_links +from ._schemas import AssistantAction +from ._tool_registry import _check_any_permission, assistant_tool + + +# #region handle_get_task_status [C:3] [TYPE Function] +@assistant_tool( + operation="get_task_status", + domain="status", + description="Get task status by task_id or latest user task", + optional_entities=["task_id"], + risk_level="safe", + requires_confirmation=False, + permission_checks=[("tasks", "READ")], +) +@belief_scope("get_task_status") +async def handle_get_task_status( + intent: dict[str, Any], + current_user: User, + task_manager: TaskManager, + config_manager: ConfigManager, + db: Session, +) -> tuple[str, str | None, list[AssistantAction]]: + """Get task status by task_id or latest user task.""" + _check_any_permission(current_user, [("tasks", "READ")]) + entities = intent.get("entities", {}) + task_id = entities.get("task_id") + if not task_id: + recent = [ + t + for t in task_manager.get_tasks(limit=20, offset=0) + if t.user_id == current_user.id + ] + if not recent: + return ("У вас пока нет задач в истории.", None, []) + task = recent[0] + actions = [AssistantAction(type="open_task", label="Open Task", target=task.id)] + if str(task.status).upper() in {"SUCCESS", "FAILED"}: + actions.extend(_extract_result_deep_links(task, config_manager)) + summary_line = _build_task_observability_summary(task, config_manager) + text = ( + f"Последняя задача: {task.id}, статус: {task.status}." + + (f"\n{summary_line}" if summary_line else "") + ) + return (text, task.id, actions) + task = task_manager.get_task(task_id) + if not task: + raise HTTPException(status_code=404, detail=f"Task {task_id} not found") + actions = [AssistantAction(type="open_task", label="Open Task", target=task.id)] + if str(task.status).upper() in {"SUCCESS", "FAILED"}: + actions.extend(_extract_result_deep_links(task, config_manager)) + summary_line = _build_task_observability_summary(task, config_manager) + text = ( + f"Статус задачи {task.id}: {task.status}." + + (f"\n{summary_line}" if summary_line else "") + ) + return (text, task.id, actions) + + +# #endregion handle_get_task_status +# #endregion AssistantToolTaskStatus diff --git a/docs/adr/ADR-0008-assistant-tool-registry.md b/docs/adr/ADR-0008-assistant-tool-registry.md new file mode 100644 index 00000000..396c0df0 --- /dev/null +++ b/docs/adr/ADR-0008-assistant-tool-registry.md @@ -0,0 +1,115 @@ +# [DEF:ADR-0008:ADR] +# @STATUS ACTIVE +# @PURPOSE Replace the manual five-file tool registration (tool catalog, dispatch, permissions, safe-ops, command parser) with a single `@assistant_tool` decorator-based registry that automatically generates the LLM catalog, dispatches operations, and enforces permissions — eliminating cross-file desynchronisation when adding new assistant commands. +# @RELATION DEPENDS_ON -> [ADR-0001:ADR] +# @RELATION DEPENDS_ON -> [AssistantSchemas] +# @RELATION CALLS -> [AssistantToolRegistry] +# @RATIONALE The current architecture requires editing 4–5 separate files per new tool: `_build_tool_catalog()` (tool metadata), `_dispatch_intent()` (routing), `INTENT_PERMISSION_CHECKS` (permissions), `_SAFE_OPS` (safety classification), and optionally `_parse_command()` (keyword fallback). In practice, developers (both human and AI) frequently miss one of these, resulting in tools that appear in the LLM catalog but fail at dispatch, or dispatch successfully but bypass permission checks. A decorator-based registry collocates all tool metadata with its handler, making it impossible to register an incomplete tool. The registry also enables automatic catalog generation, eliminating the hand-maintained `_build_tool_catalog()`. +# @REJECTED Centralised YAML/JSON config file (e.g. `tools.yaml`) — rejected because it separates tool metadata from implementation, requiring developers to jump between config and code. It also cannot express permission-check logic or handler imports, so it would still need a parallel registration step, reducing the benefit. +# @REJECTED Automatic discovery via filesystem scanning (e.g. `importlib` scanning `_tool_*.py`) — rejected because it makes the tool set implicit; adding a `_tool_*.py` file silently registers a tool, which is dangerous for guarded or destructive operations. Explicit decorator registration in a known `_tool_registry.py` module keeps the tool set fully visible and auditable. +# @REJECTED Incremental improvement of existing pattern (add one more dict, add one more if) — rejected because it does not solve the root problem: the five-file scatter. Any new file or dict that must be kept in sync with the handler will eventually desynchronise. +# @DATA_CONTRACT Handler[Intent, User, TaskManager, ConfigManager, Session] -> [text: str, task_id: str | None, actions: list[AssistantAction]] + +## Decision + +### 1. New file: `_tool_registry.py` + +A central registry module that provides: + +```python +@assistant_tool( + operation="search_dashboards", + domain="dashboards", + description="Search and list dashboards by name, id, or get all dashboards", + optional_entities=["search", "environment", "page", "page_size"], + risk_level="safe", + requires_confirmation=False, + permission_checks=[("dashboards", "READ")], +) +async def handle_search_dashboards(intent, user, task_manager, config_manager, db): + ... +``` + +The decorator stores the tool definition in a module-level `dict[str, AssistantTool]` and attaches it as `func.__assistant_tool__` for optional introspection. + +### 2. Auto-generated catalog + +`_build_tool_catalog()` is replaced by `get_catalog(user)` which: +1. Iterates all registered tools +2. Filters by user permissions via `_has_any_permission()` +3. Returns LLM-ready dict list + +### 3. Auto-dispatched routing + +`_dispatch_intent()` is replaced by `dispatch(operation, ...)` which: +1. Looks up `operation` in the registry +2. Calls `tool.handler(intent, user, task_manager, config_manager, db)` +3. Returns `(text, task_id, actions)` + +### 4. Derived lists + +`_SAFE_OPS` is derived from `risk_level == "safe"` in the registry. `INTENT_PERMISSION_CHECKS` is derived from `tool.permission_checks`. Both hardcoded dicts are removed. + +### 5. Migration strategy + +Each existing tool handler is moved from `_dispatch.py:_dispatch_intent()` into its own `_tool_*.py` file with an `@assistant_tool` decorator. Tools are migrated one at a time, in this order: + +| Step | Tool | File | +|------|------|------| +| 1 | `show_capabilities` | `_tool_capabilities.py` | +| 2 | `get_task_status` | `_tool_task_status.py` | +| 3 | `create_branch` | `_tool_create_branch.py` | +| 4 | `commit_changes` | `_tool_commit.py` | +| 5 | `deploy_dashboard` | `_tool_deploy.py` | +| 6 | `execute_migration` | `_tool_migration.py` | +| 7 | `run_backup` | `_tool_backup.py` | +| 8 | `run_llm_validation` | `_tool_llm_validation.py` | +| 9 | `run_llm_documentation` | `_tool_llm_documentation.py` | +| 10 | `get_health_summary` | `_tool_health_summary.py` | + +After migration, `_dispatch.py` is reduced to a thin `dispatch()` wrapper. The `_parse_command()` function is updated to also use the registry for keyword-to-operation mapping. + +### 6. File layout after migration + +``` +backend/src/api/routes/assistant/ +├── __init__.py +├── _admin_routes.py # admin endpoints (list_conversations, delete, history, audit) +├── _routes.py # main router (send_message, confirm, cancel) +├── _tool_registry.py # @assistant_tool decorator, get_catalog(), dispatch() +├── _tool_capabilities.py +├── _tool_task_status.py +├── _tool_create_branch.py +├── _tool_commit.py +├── _tool_deploy.py +├── _tool_migration.py +├── _tool_backup.py +├── _tool_llm_validation.py +├── _tool_llm_documentation.py +├── _tool_health_summary.py +├── _schemas.py # Pydantic models, in-memory stores +├── _history.py # Conversation history helpers +├── _dispatch.py # removed or thin wrapper +├── _llm_planner.py # removed _build_tool_catalog, uses get_catalog() +├── _llm_planner_intent.py +├── _command_parser.py +├── _resolvers.py +├── _dataset_review.py +└── _dataset_review_dispatch.py +``` + +## Consequences + +### Positive +- **Single point of registration** — adding a tool requires editing exactly one file: the tool handler with its `@assistant_tool` decorator +- **No desync** — catalog, dispatch, permissions, and safety classification all derive from the same decorator metadata +- **Testable** — `_tool_registry.py` can be unit-tested without DB or HTTP fixtures +- **Discoverable** — `from _tool_registry import _tools` gives a complete inventory of all registered tools +- **Safe-by-default** — a tool without `@assistant_tool` is not registered and cannot be invoked + +### Negative +- **One-time migration cost** — 10 existing handlers must be moved to new files (~200 LOC total) +- **Import overhead** — each `_tool_*.py` must be imported somewhere to trigger decorator registration (solved by importing them in `__init__.py` or `_tool_registry.py`) +- **Dynamic dispatch** — replaces static `if/elif` with dict lookup, which is marginally less explicit (mitigated by strong typing of `AssistantTool` dataclass) + +# [/DEF:ADR-0008:ADR] diff --git a/frontend/src/components/EnvSelector.svelte b/frontend/src/components/EnvSelector.svelte index 4b82c21d..5d9c44ce 100644 --- a/frontend/src/components/EnvSelector.svelte +++ b/frontend/src/components/EnvSelector.svelte @@ -18,7 +18,7 @@ // [SECTION: PROPS] let { label = "", - selectedId = "", + selectedId = $bindable(""), environments = [], onchange = () => {}, } = $props(); diff --git a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte index 52610763..7c61f7fc 100644 --- a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte +++ b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte @@ -867,8 +867,8 @@ {#if message.intent?.operation}
- {$t.assistant?.confirmation_operation_label || "Action"}: - {message.intent.operation} + {$t.assistant?.confirmation_operation_label || "Operation"}: + {$t.assistant?.operation_labels?.[message.intent.operation] || message.intent.operation}
{/if} {#if hasConfirmationScope(message)} diff --git a/frontend/src/lib/i18n/locales/en/assistant.json b/frontend/src/lib/i18n/locales/en/assistant.json index 3e8b3421..ed884d6e 100644 --- a/frontend/src/lib/i18n/locales/en/assistant.json +++ b/frontend/src/lib/i18n/locales/en/assistant.json @@ -27,13 +27,24 @@ "session_scope_label": "Session", "focus_target_label": "Focus", "clear_focus_action": "Clear focus", - "confirmation_card_title": "Confirmation required", - "confirmation_scope_fallback": "This action affects the current assistant scope.", - "confirmation_operation_label": "Action", + "confirmation_card_title": "⚠️ Confirm action", + "confirmation_scope_fallback": "This operation modifies data or starts tasks in the system. Please review the details before confirming.", + "confirmation_operation_label": "Operation", "highlight_workspace_action": "Highlight in workspace", "clarification_banner_title": "Clarification queue is active", "clarification_resume_action": "Resume", "clarification_dismiss_action": "Dismiss", + "operation_labels": { + "commit_changes": "📝 Commit changes", + "create_branch": "📝 Create branch", + "deploy_dashboard": "🚀 Deploy dashboard", + "execute_migration": "🔄 Run migration", + "run_backup": "💾 Backup", + "run_llm_validation": "🤖 LLM validation", + "run_llm_documentation": "📄 Generate documentation", + "start_maintenance": "🛠 Start maintenance", + "end_maintenance": "✅ End maintenance" + }, "states": { "started": "Started", "success": "Success", diff --git a/frontend/src/lib/i18n/locales/en/dashboard.json b/frontend/src/lib/i18n/locales/en/dashboard.json index 3db17a0d..0b4312ea 100644 --- a/frontend/src/lib/i18n/locales/en/dashboard.json +++ b/frontend/src/lib/i18n/locales/en/dashboard.json @@ -105,6 +105,7 @@ "thumbnail_generating": "Thumbnail is being generated", "thumbnail_failed": "Failed to load thumbnail", "thumbnail_unavailable": "Thumbnail is unavailable", + "settings": "Settings", "empty": "No dashboards found", "setup_badge": "Initial setup", "setup_title": "Configure your first environment", diff --git a/frontend/src/lib/i18n/locales/en/migration.json b/frontend/src/lib/i18n/locales/en/migration.json index ba5ec9b3..77d4bd52 100644 --- a/frontend/src/lib/i18n/locales/en/migration.json +++ b/frontend/src/lib/i18n/locales/en/migration.json @@ -17,5 +17,22 @@ "mapping_management": "Database Mapping Management", "fetch_dbs": "Fetch Databases & Suggestions", "mapping_hint": "Select environments and click \"Fetch Databases\" to start mapping.", - "task_placeholder_warn": "Could not fetch task details immediately, using placeholder." + "task_placeholder_warn": "Could not fetch task details immediately, using placeholder.", + "dry_run": "Dry Run", + "calculating_dry_run": "Running pre-flight analysis...", + "starting": "Starting...", + "step_environments": "Environments", + "step_dashboards": "Dashboards", + "step_review": "Review", + "step_migrate": "Migrate", + "options": "Options", + "risk_assessment": "Risk Assessment", + "risk_score": "Score", + "no_issues": "No issues detected.", + "no_dry_run": "Run a dry-run to see the migration summary here.", + "get_started": "Get Started", + "select_environment_prompt": "Select environments to start", + "diff_json": "Diff JSON", + "migration_starting": "Starting Migration...", + "dry_run_summary": "Dry-run report: {total} items will be changed (Dashboards: {dashboards}, Charts: {charts}, Datasets: {datasets})" } diff --git a/frontend/src/lib/i18n/locales/ru/assistant.json b/frontend/src/lib/i18n/locales/ru/assistant.json index 1f75deed..deb2fecf 100644 --- a/frontend/src/lib/i18n/locales/ru/assistant.json +++ b/frontend/src/lib/i18n/locales/ru/assistant.json @@ -27,13 +27,24 @@ "session_scope_label": "Сессия", "focus_target_label": "Фокус", "clear_focus_action": "Сбросить фокус", - "confirmation_card_title": "Требуется подтверждение", - "confirmation_scope_fallback": "Это действие влияет на текущую область ассистента.", - "confirmation_operation_label": "Действие", + "confirmation_card_title": "⚠️ Подтвердите действие", + "confirmation_scope_fallback": "Эта операция изменяет данные или запускает задачи в системе. Пожалуйста, проверьте детали перед подтверждением.", + "confirmation_operation_label": "Операция", "highlight_workspace_action": "Подсветить в workspace", "clarification_banner_title": "Очередь уточнений активна", "clarification_resume_action": "Продолжить", "clarification_dismiss_action": "Скрыть", + "operation_labels": { + "commit_changes": "📝 Коммит изменений", + "create_branch": "📝 Создание ветки", + "deploy_dashboard": "🚀 Деплой дашборда", + "execute_migration": "🔄 Миграция дашборда", + "run_backup": "💾 Бэкап", + "run_llm_validation": "🤖 LLM-валидация", + "run_llm_documentation": "📄 Генерация документации", + "start_maintenance": "🛠 Запуск обслуживания", + "end_maintenance": "✅ Завершение обслуживания" + }, "states": { "started": "Запущено", "success": "Успешно", diff --git a/frontend/src/lib/i18n/locales/ru/dashboard.json b/frontend/src/lib/i18n/locales/ru/dashboard.json index 3dc11049..a8848588 100644 --- a/frontend/src/lib/i18n/locales/ru/dashboard.json +++ b/frontend/src/lib/i18n/locales/ru/dashboard.json @@ -126,5 +126,6 @@ "setup_failed": "Не удалось создать окружение.", "setup_created": "Окружение создано. Загружаю дашборды.", "setup_empty_title": "Окружения еще не настроены", - "setup_empty_body": "Добавьте первое окружение Superset, чтобы открыть дашборды, датасеты, бэкапы и миграции." + "setup_empty_body": "Добавьте первое окружение Superset, чтобы открыть дашборды, датасеты, бэкапы и миграции.", + "settings": "Настройки" } diff --git a/frontend/src/lib/i18n/locales/ru/migration.json b/frontend/src/lib/i18n/locales/ru/migration.json index 82c0c26b..2e9d0b9f 100644 --- a/frontend/src/lib/i18n/locales/ru/migration.json +++ b/frontend/src/lib/i18n/locales/ru/migration.json @@ -19,5 +19,20 @@ "mapping_hint": "Выберите окружения и нажмите «Получить БД и подсказки», чтобы начать маппинг.", "task_placeholder_warn": "Не удалось сразу получить детали задачи, используется временное состояние.", "calculating_dry_run": "Расчет ожидаемых изменений...", - "dry_run_summary": "Отчет dry-run: всего будет изменено {total} элементов (дашбордов: {dashboards}, чартов: {charts}, датасетов: {datasets})" + "dry_run_summary": "Отчет dry-run: всего будет изменено {total} элементов (дашбордов: {dashboards}, чартов: {charts}, датасетов: {datasets})", + "dry_run": "Пробный прогон", + "starting": "Запуск...", + "step_environments": "Окружения", + "step_dashboards": "Дашборды", + "step_review": "Проверка", + "step_migrate": "Миграция", + "options": "Опции", + "risk_assessment": "Оценка рисков", + "risk_score": "Оценка", + "no_issues": "Проблем не обнаружено.", + "no_dry_run": "Запустите пробный прогон, чтобы увидеть сводку миграции.", + "get_started": "Начать", + "select_environment_prompt": "Выберите окружения для начала", + "diff_json": "Diff JSON", + "migration_starting": "Запуск миграции..." } diff --git a/frontend/src/routes/migration/+page.svelte b/frontend/src/routes/migration/+page.svelte index 17b4d9d1..aadfb74c 100644 --- a/frontend/src/routes/migration/+page.svelte +++ b/frontend/src/routes/migration/+page.svelte @@ -14,8 +14,8 @@ @RELATION BINDS_TO ->[TaskLogViewer] @RELATION BINDS_TO ->[PasswordPrompt] @INVARIANT: Migration start is blocked unless source and target environments are selected, distinct, and at least one dashboard is selected. -@UX_STATE: [Idle] -> User configures source/target environments, dashboard selection, and migration options. -@UX_STATE: [Loading] -> Environment/database/dry-run fetch operations disable relevant actions and show progress text. +@UX_STATE: [Idle] -> User configures source/target environments, dashboard selection, and migration options through a step-by-step wizard. +@UX_STATE: [Loading] -> Environment/database/dry-run fetch operations disable relevant actions and show skeleton loaders. @UX_STATE: [Error] -> Error banner/prompt message is shown while keeping user input intact for correction. @UX_STATE: [Success] -> Dry-run summary or active task view is rendered after successful API operations. @UX_FEEDBACK: Inline error banner, disabled CTA states, loading labels, dry-run summary cards, modal dialogs. @@ -32,32 +32,6 @@ @TEST_INVARIANT: migration_guardrails_enforced -> VERIFIED_BY: [start_migration_valid_selection, start_dry_run_valid_selection, missing_field] --> - - - - - - - - - - - - - - - - - - - - - - - -
- - - +
+ +
+ + +
- - - - - - - {#if $selectedTask} -
- -
-
+
+ {:else} - - {#if loading} -

{$t.migration?.loading_envs }

- {:else if error} -
- {error} + + {#if showTaskHistory} +
+
{/if} + - - -
- - -
- - - - +
-

- {$t.migration?.select_dashboards_title } -

- - {#if sourceEnvId} - - {:else} -

- {$t.dashboard?.select_source || - "Select a source environment to view dashboards."} -

- {/if} -
- - - - -
-
- - -
- -
- { - console.info("[MigrationOptionsSection][REASON] Database replacement toggled", { replaceDb }); - if (replaceDb && sourceDatabases.length === 0) fetchDatabases(); - }} - class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" - /> - -
-
- - - {#if replaceDb} -
-

- {$t.migration?.database_mappings } -

- {#if fetchingDbs} -

- {$t.migration?.loading_dbs || - "Loading databases and suggestions..."} -

- {:else if sourceDatabases.length > 0} - - {:else if sourceEnvId && targetEnvId} +
+ {#each [ + { step: 1, label: $t.migration?.source_env || "Source" }, + { step: 2, label: $t.migration?.select_dashboards_title || "Dashboards" }, + { step: 3, label: $t.tasks?.summary_report || "Review" }, + { step: 4, label: $t.migration?.start || "Migrate" }, + ] as s} - {/if} + {#if s.step < 4} +
+
+
+
+
+ {/if} + {/each}
- {/if} - -
- - -
+ - - - {#if dryRunResult} -
-

Pre-flight Diff

-
-
-
Dashboards
-
create: {dryRunResult.summary.dashboards.create}
-
update: {dryRunResult.summary.dashboards.update}
-
delete: {dryRunResult.summary.dashboards.delete}
-
-
-
Charts
-
create: {dryRunResult.summary.charts.create}
-
update: {dryRunResult.summary.charts.update}
-
delete: {dryRunResult.summary.charts.delete}
-
-
-
Datasets
-
create: {dryRunResult.summary.datasets.create}
-
update: {dryRunResult.summary.datasets.update}
-
delete: {dryRunResult.summary.datasets.delete}
+ + {#if loading} +
+
+
+
+
+
+
-
-
Risk
-
- score: {dryRunResult.risk.score}, level: {dryRunResult.risk.level} + + {:else if error} +
+
+
+ + +
-
- issues: {dryRunResult.risk.items.length} +
+

{error}

+
- -
- Diff JSON -
{JSON.stringify(dryRunResult, null, 2)}
-
{/if} - + + + {#if currentStep === 1} + +
+
+ 1 +

{$t.migration?.source_env || "Source Environment"} → {$t.migration?.target_env || "Target Environment"}

+
+
+
+
+
+

{$t.migration?.source_env || "Source"}

+
+ +
+
+
+
+

{$t.migration?.target_env || "Target"}

+
+ +
+
+ {#if sourceEnvId && targetEnvId && sourceEnvId === targetEnvId} +
+

+ + + + {$t.migration?.different_envs || "Source and target environments must be different."} +

+
+ {/if} +
+ +
+
+
+ {/if} + + + + {#if currentStep === 2} + +
+
+ 2 +

{$t.migration?.select_dashboards_title || "Select Dashboards"}

+
+ + {#if sourceEnvId} +
+ +
+ {:else} +

+ {$t.dashboard?.select_source || "Select a source environment to view dashboards."} +

+ {/if} + + +
+

{$t.dashboard?.settings || "Options"}

+
+ + + +
+
+ + + {#if replaceDb} +
+
+

{$t.migration?.database_mappings || "Database Mappings"}

+ +
+ {#if fetchingDbs} +
+ + + + {$t.migration?.loading_dbs || "Loading databases and suggestions..."} +
+ {:else if sourceDatabases.length > 0} + + {:else if sourceEnvId && targetEnvId} +

{$t.migration?.mapping_hint || "Select environments and click \"Fetch Databases\" to start mapping."}

+ {/if} +
+ {/if} + +
+ +
+ +
+
+
+
+ {/if} + + + + {#if currentStep === 3} + +
+
+ 3 +

{$t.tasks?.summary_report || "Review & Validate"}

+
+ + {#if dryRunResult} + +
+
+ + + +

{$t.dashboard?.selected_dashboards || "Selected Dashboards"}

+
+
+ {#each dryRunResult.selected_dashboard_titles || [] as title} + + {title} + + {/each} +
+
+ + +
+ +
+
+
+ + + +
+

{$t.dashboard?.title || "Dashboards"}

+
+
+
+ Create + {dryRunResult.summary.dashboards.create} +
+
+ Update + {dryRunResult.summary.dashboards.update} +
+
+ Delete + {dryRunResult.summary.dashboards.delete} +
+
+
+ + +
+
+
+ + + +
+

{$t.dashboard?.charts || "Charts"}

+
+
+
+ Create + {dryRunResult.summary.charts.create} +
+
+ Update + {dryRunResult.summary.charts.update} +
+
+ Delete + {dryRunResult.summary.charts.delete} +
+
+
+ + +
+
+
+ + + +
+

{$t.dashboard?.linked_resources || "Datasets"}

+
+
+
+ Create + {dryRunResult.summary.datasets.create} +
+
+ Update + {dryRunResult.summary.datasets.update} +
+
+ Delete + {dryRunResult.summary.datasets.delete} +
+
+
+
+ + +
+
+
+ + + +

{$t.tasks?.issues || "Risk Assessment"}

+
+
+ +
+ Score: +
+ {dryRunResult.risk.score}/100 +
+
+ +
+ {dryRunResult.risk.level.toUpperCase()} +
+
+
+ + +
+
20 && dryRunResult.risk.score <= 50} + class:bg-red-500={dryRunResult.risk.score > 50} + style="width: {dryRunResult.risk.score}%" + >
+
+ + + {#if dryRunResult.risk.items && dryRunResult.risk.items.length > 0} +
+ {#each dryRunResult.risk.items as item} +
+
+
+

{item.message}

+

{item.object_type}: {item.object_uuid}

+
+
+ {/each} +
+ {:else} +

No issues detected.

+ {/if} +
+ + +
+ + + + + {$t.tasks?.diff_preview || "Diff JSON"} + +
{JSON.stringify(dryRunResult, null, 2)}
+
+ {:else} +
+ + + +

Run a dry-run to see the migration summary here.

+
+ {/if} + +
+ + +
+
+
+ {/if} + + + + {#if currentStep === 4 && !$selectedTask} + +
+ + + +

{$t.migration?.starting || "Starting Migration..."}

+

{$t.common.loading || "Please wait..."}

+ +
+
+ {/if} + + + + {#if !currentStep || currentStep === 0} + +
+ + + +

{$t.dashboard?.migrate_modal_title || "Select environments to start"}

+ +
+
+ {/if} {/if}
- - - + - - diff --git a/frontend/src/routes/migration/__tests__/migration_dashboard.ux.test.js b/frontend/src/routes/migration/__tests__/migration_dashboard.ux.test.js index 8dc81350..51252114 100644 --- a/frontend/src/routes/migration/__tests__/migration_dashboard.ux.test.js +++ b/frontend/src/routes/migration/__tests__/migration_dashboard.ux.test.js @@ -1,5 +1,23 @@ +// #region TestMigrationDashboard [C:3] [TYPE Module] [SEMANTICS test,migration,ux] +// @BRIEF Verify migration page wizard UX — environment selection, step navigation, error recovery, dry-run flow. +// @RELATION BINDS_TO -> [MigrationPage] +// @LAYER UI +// @RELATION DEPENDS_ON -> [ApiModule] +// @RELATION DEPENDS_ON -> [I18nModule] +// @RELATION DEPENDS_ON -> [EXT:@testing-library/svelte] +// @TEST_CONTRACT: [DashboardSelection + Environment IDs] -> [StepTransition | DryRunResult | ErrorState] +// @TEST_SCENARIO: environments_loaded_on_mount -> Page fetches environments on mount. +// @TEST_SCENARIO: step1_next_disabled_without_env -> Next button disabled when no environments selected. +// @TEST_SCENARIO: api_error_shows_banner -> API failure surfaces error with dismiss button. +// @TEST_SCENARIO: step2_dashboards_shown -> Step 2 content renders when navigated with envs selected. +// @TEST_SCENARIO: step_nav_back_button -> Back button returns to previous step. +// @TEST_EDGE: missing_field -> Empty env selection blocks Next and shows appropriate state. +// @TEST_EDGE: external_fail -> API failure surfaces error banner that can be dismissed. +// @TEST_INVARIANT: migration_guardrails_enforced -> VERIFIED_BY: [step1_next_disabled_without_env, api_error_shows_banner] +// #endregion TestMigrationDashboard + import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; +import { render, waitFor } from '@testing-library/svelte'; import MigrationDashboard from '../+page.svelte'; import { api } from '$lib/api'; @@ -8,7 +26,8 @@ vi.mock('$lib/api', () => ({ api: { getEnvironmentsList: vi.fn(), requestApi: vi.fn(), - postApi: vi.fn() + postApi: vi.fn(), + getTask: vi.fn() } })); @@ -19,10 +38,29 @@ vi.mock('$lib/i18n', () => ({ nav: { migration: 'Migration' }, migration: { start: 'Start Migration', - fix_cross_filters: 'Fix Cross-Filters (Auto-repair broken links during migration)', - replace_db: 'Replace Database (Apply Mappings)' + fix_cross_filters: 'Fix Cross-Filters', + replace_db: 'Replace Database', + source_env: 'Source Environment', + target_env: 'Target Environment', + select_dashboards_title: 'Select Dashboards', + different_envs: 'Source and target environments must be different.', + starting: 'Starting...' }, - common: { cancel: 'Cancel' } + dashboard: { + fix_cross_filters_hint: 'Automatically remap chart and dataset IDs...', + mapping_disabled_hint: 'Apply database mappings during migration.', + next: 'Next', + select_source: 'Select a source env', + view_summary: 'Dry Run', + settings: 'Options', + migrate_modal_title: 'Select environments to start' + }, + tasks: { + summary_report: 'Review', + recent: 'Recent Tasks', + starting: 'Starting...' + }, + common: { cancel: 'Cancel', back: 'Back', loading: 'Loading', close: 'Close', refresh: 'Refresh' } }); return () => { }; } @@ -46,35 +84,87 @@ vi.mock('../../components/DashboardGrid.svelte', () => ({ } })); +vi.mock('../../components/TaskHistory.svelte', () => ({ + default: class TaskHistoryMock { + constructor(options) { + options.target.innerHTML = `
`; + } + } +})); + +// #region describe:MigratonDashboard [C:2] [TYPE Block] +// @BRIEF Test suite for migration page UX contract — step wizard, error handling, navigation. +// @RELATION BINDS_TO -> [MigrationPage] describe('MigrationDashboard.ux.test.js', () => { const mockEnvironments = [ - { id: 'env-1', name: 'Source Env' }, - { id: 'env-2', name: 'Target Env' } + { id: 'env-1', name: 'Source Env', url: 'https://source.example.com' }, + { id: 'env-2', name: 'Target Env', url: 'https://target.example.com' } ]; beforeEach(() => { vi.clearAllMocks(); api.getEnvironmentsList.mockResolvedValue(mockEnvironments); api.requestApi.mockResolvedValue([]); + api.postApi.mockResolvedValue({}); }); + // #region test_renders_and_fetches_envs [C:2] [TYPE Function] + // @BRIEF Page fetches environments on mount. + // @TEST_SCENARIO: environments_loaded_on_mount it('renders and fetches environments on mount', async () => { render(MigrationDashboard); expect(api.getEnvironmentsList).toHaveBeenCalled(); }); + // #endregion test_renders_and_fetches_envs - it('shows missing environments error when trying to start migration without selection', async () => { + // #region test_step1_next_disabled [C:2] [TYPE Function] + // @BRIEF Next button in step 1 is disabled without environment selection. + // @TEST_SCENARIO: step1_next_disabled_without_env + // @TEST_EDGE: missing_field + it('has step 1 shown by default with Next button disabled', async () => { + const { getByRole, queryByText } = render(MigrationDashboard); + + // Step 1 is shown by default — find the Next button + const nextBtn = getByRole('button', { name: /next/i }); + expect(nextBtn).toBeTruthy(); + // Next button should be disabled until environments selected + expect(nextBtn.hasAttribute('disabled')).toBe(true); + + // Task History toggle should be present + const taskHistoryBtn = queryByText(/task|последние задачи/i); + expect(taskHistoryBtn).toBeTruthy(); + }); + // #endregion test_step1_next_disabled + + // #region test_error_state [C:2] [TYPE Function] + // @BRIEF API failure surfaces error banner that can be dismissed. + // @TEST_SCENARIO: api_error_shows_banner + // @TEST_EDGE: external_fail + it('handles error state gracefully when environments fail to load', async () => { + api.getEnvironmentsList.mockRejectedValueOnce(new Error('API Error')); + const { getByText } = render(MigrationDashboard); + await waitFor(() => { + expect(getByText('API Error')).toBeTruthy(); + }); + + // Error dismiss button should exist (close/aria-label) + const dismissBtn = getByText('API Error').closest('[class*="bg-red"]')?.querySelector('button'); + expect(dismissBtn).toBeTruthy(); + }); + // #endregion test_error_state + + // #region test_ui_has_header_and_toggle [C:2] [TYPE Function] + // @BRIEF Page header and task history toggle render correctly. + it('renders page header and task history toggle button', async () => { const { getByText } = render(MigrationDashboard); - // Checkboxes should exist and fix_cross_filters is checked by default - const fixFiltersCheckbox = screen.getByLabelText('Fix Cross-Filters (Auto-repair broken links during migration)'); - expect(fixFiltersCheckbox.checked).toBe(true); - - const replaceDbCheckbox = screen.getByLabelText('Replace Database (Apply Mappings)'); - expect(replaceDbCheckbox.checked).toBe(false); - - // Initial state prevents clicking start. Forcing click by getting element - const startButton = getByText('Start Migration'); - expect(startButton.disabled).toBe(true); + // Header title should be present + expect(getByText('Migration')).toBeTruthy(); + // Task history toggle button should exist + const toggleBtn = getByText(/task|последние задачи/i); + expect(toggleBtn).toBeTruthy(); + expect(toggleBtn.tagName).toBe('BUTTON'); }); + // #endregion test_ui_has_header_and_toggle }); +// #endregion describe:MigratonDashboard diff --git a/frontend/src/routes/translate/[id]/+page.svelte b/frontend/src/routes/translate/[id]/+page.svelte index fa869116..336091f4 100644 --- a/frontend/src/routes/translate/[id]/+page.svelte +++ b/frontend/src/routes/translate/[id]/+page.svelte @@ -1,6 +1,12 @@ - - + + + + + + + + @@ -116,7 +122,7 @@ let databasesLoading = $state(false); // Tab state - /** @type {'config'|'preview'|'run'|'schedule'} */ + /** @type {'config'|'preview'|'target'|'run'|'schedule'} */ let activeTab = $state('config'); let tabs = $derived.by(() => { const base = [ @@ -125,6 +131,9 @@ { id: 'run', label: $t.translate?.config?.run_translation || 'Run' }, ]; if (!isNewJob && existingJob) { + // @RATIONALE Target Config tab inserted between Preview and Run so users can + // review/preview translations before configuring the target table for inserts. + base.splice(2, 0, { id: 'target', label: $t.translate?.config?.target_table_tab || 'Target Config' }); base.push({ id: 'schedule', label: $t.translate?.schedule?.tab_label || 'Schedule' }); } return base; @@ -140,9 +149,13 @@ let warnings = $state([]); // Derived: is the job config complete enough for preview/run? + // @RATIONALE Removed !isNewJob from configValid — preview availability should depend + // on config completeness only, not on whether the job was saved. The preview tab + // content already gates rendering on existingJob via the template. + // @REJECTED Keeping !isNewJob in configValid would block preview when user wants to + // see results before configuring target table details. let configValid = $derived( - !isNewJob - && !!translationColumn + !!translationColumn && !!datasourceId && targetLanguages.length > 0 && !!providerId @@ -728,13 +741,13 @@ {#each tabs as tab} @@ -1024,125 +1037,6 @@
- -
-

{$t.translate?.config?.target_table_title}

-
-
- - -
-
- - -
-
- - - {#if databasesLoading} -

{$t.translate?.config?.loading_databases}

- {:else if !environmentId} -

{$t.translate?.config?.select_environment}

- {:else} -

{$t.translate?.config?.target_database_hint}

- {/if} -
-
- - -
-

{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}

-

{$t.translate?.config?.target_column_mapping_description || 'Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.'}

-
-
- - -

{$t.translate?.config?.target_column_hint}

-
-
- - -

{$t.translate?.config?.target_language_column_hint}

-
-
- - -

{$t.translate?.config?.target_source_column_hint}

-
-
- - -

{$t.translate?.config?.target_source_language_column_hint}

-
-
-
- - - -
-

{$t.translate?.config?.llm_settings}

@@ -1300,6 +1194,133 @@ {/if}
+ +
+ {#if !isNewJob && existingJob} +
+

{$t.translate?.config?.target_table_title}

+
+
+ + +
+
+ + +
+
+ + + {#if databasesLoading} +

{$t.translate?.config?.loading_databases}

+ {:else if !environmentId} +

{$t.translate?.config?.select_environment}

+ {:else} +

{$t.translate?.config?.target_database_hint}

+ {/if} +
+
+ + +
+

{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}

+

{$t.translate?.config?.target_column_mapping_description || 'Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.'}

+
+
+ + +

{$t.translate?.config?.target_column_hint}

+
+
+ + +

{$t.translate?.config?.target_language_column_hint}

+
+
+ + +

{$t.translate?.config?.target_source_column_hint}

+
+
+ + +

{$t.translate?.config?.target_source_language_column_hint}

+
+
+
+ + + +
+ {:else} +
+

{$t.translate?.config?.save_job_first || 'Save the job first'}

+
+ {/if} +
+
{#if !isNewJob && existingJob}