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}