Files
ss-tools/backend/src/api/routes/assistant/_dispatch.py
2026-05-08 10:07:05 +03:00

310 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# [DEF:AssistantDispatch:Module]
# @COMPLEXITY: 5
# @PURPOSE: Intent dispatch engine, confirmation summary, and clarification text for the assistant API.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [AssistantSchemas]
# @RELATION: DEPENDS_ON -> [AssistantResolvers]
# @RELATION: DEPENDS_ON -> [AssistantLlmPlanner]
# @RELATION: DEPENDS_ON -> [AssistantDatasetReview]
# @INVARIANT: Unsupported operations are rejected via HTTPException(400).
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy.orm import Session
from src.core.logger import belief_scope, logger
from src.core.config_manager import ConfigManager
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 src.services.llm_prompt_templates import is_multimodal_model
from ._schemas import (
_DATASET_REVIEW_OPS,
AssistantAction,
)
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 ._history import _coerce_query_bool
from ._llm_planner import _check_any_permission
from ._dataset_review import _dispatch_dataset_review_intent
git_service = GitService()
# [DEF:_clarification_text_for_intent:Function]
# @COMPLEXITY: 2
# @PURPOSE: Convert technical missing-parameter errors into user-facing clarification prompts.
# @PRE: state was classified as needs_clarification for current intent/error combination.
# @POST: Returned text is human-readable and actionable for target operation.
def _clarification_text_for_intent(
intent: Optional[Dict[str, Any]], detail_text: str
) -> str:
operation = (intent or {}).get("operation")
guidance_by_operation: Dict[str, str] = {
"run_llm_validation": (
"Нужно уточнение для запуска LLM-валидации: Укажите дашборд (id или slug), окружение и провайдер LLM."
),
"run_llm_documentation": (
"Нужно уточнение для генерации документации: Укажите dataset_id, окружение и провайдер LLM."
),
"create_branch": "Нужно уточнение: укажите дашборд (id/slug/title) и имя ветки.",
"commit_changes": "Нужно уточнение: укажите дашборд (id/slug/title) для коммита.",
"deploy_dashboard": "Нужно уточнение: укажите дашборд (id/slug/title) и целевое окружение.",
"execute_migration": "Нужно уточнение: укажите дашборд (id/slug/title), source_env и target_env.",
"run_backup": "Нужно уточнение: укажите окружение и при необходимости дашборд (id/slug/title).",
}
return guidance_by_operation.get(operation, detail_text)
# [/DEF:_clarification_text_for_intent:Function]
# [DEF:_async_confirmation_summary:Function]
# @COMPLEXITY: 4
# @PURPOSE: Build human-readable confirmation prompt for an intent before execution.
# @PRE: actions is a non-empty list of planned review actions.
# @POST: Returns a formatted summary string suitable for display to the user.
# @SIDE_EFFECT: None - pure formatting function.
async def _async_confirmation_summary(intent: Dict[str, Any], config_manager: ConfigManager, db: Session) -> str:
with belief_scope('_confirmation_summary'):
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:
logger.reflect('Belief protocol postcondition checkpoint for _confirmation_summary')
return 'Подтвердите выполнение операции или отмените.'
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')))
if operation == 'execute_migration':
flags = []
flags.append('маппинг БД: ' + ('ВКЛ' if _coerce_query_bool(entities.get('replace_db_config', False)) else 'ВЫКЛ'))
flags.append('исправление кроссфильтров: ' + ('ВКЛ' if _coerce_query_bool(entities.get('fix_cross_filters', True)) else 'ВЫКЛ'))
dry_run_enabled = _coerce_query_bool(entities.get('dry_run', False))
flags.append('отчет dry-run: ' + ('ВКЛ' if dry_run_enabled else 'ВЫКЛ'))
text += f" ({', '.join(flags)})"
if dry_run_enabled:
try:
from src.core.migration.dry_run_orchestrator import MigrationDryRunService
from src.models.dashboard import DashboardSelection
from src.core.superset_client import SupersetClient
src_token = entities.get('source_env')
tgt_token = entities.get('target_env')
dashboard_id = _resolve_dashboard_id_entity(entities, config_manager, env_hint=src_token)
if dashboard_id and src_token and tgt_token:
src_env_id = _resolve_env_id(src_token, config_manager)
tgt_env_id = _resolve_env_id(tgt_token, config_manager)
if src_env_id and tgt_env_id:
env_map = {env.id: env for env in config_manager.get_environments()}
source_env = env_map.get(src_env_id)
target_env = env_map.get(tgt_env_id)
if source_env and target_env and (source_env.id != target_env.id):
selection = DashboardSelection(source_env_id=source_env.id, target_env_id=target_env.id, selected_ids=[dashboard_id], replace_db_config=_coerce_query_bool(entities.get('replace_db_config', False)), fix_cross_filters=_coerce_query_bool(entities.get('fix_cross_filters', True)))
service = MigrationDryRunService()
source_client = SupersetClient(source_env)
target_client = SupersetClient(target_env)
report = service.run(selection, source_client, target_client, db)
s = report.get('summary', {})
dash_s = s.get('dashboards', {})
charts_s = s.get('charts', {})
ds_s = s.get('datasets', {})
creates = dash_s.get('create', 0) + charts_s.get('create', 0) + ds_s.get('create', 0)
updates = dash_s.get('update', 0) + charts_s.get('update', 0) + ds_s.get('update', 0)
deletes = dash_s.get('delete', 0) + charts_s.get('delete', 0) + ds_s.get('delete', 0)
text += f'\n\nОтчет dry-run:\n- Будет создано новых объектов: {creates}\n- Будет обновлено: {updates}\n- Будет удалено: {deletes}'
else:
text += '\n\n(Не удалось загрузить отчет dry-run: неверные окружения).'
except Exception as e:
import traceback
logger.warning('[assistant.dry_run_summary][failed] Exception: %s\n%s', e, traceback.format_exc())
text += f'\n\n(Не удалось загрузить отчет dry-run: {e}).'
logger.reflect('Belief protocol postcondition checkpoint for _confirmation_summary')
return f'Выполнить: {text}. Подтвердите или отмените.'
# [/DEF:_async_confirmation_summary:Function]
# [DEF:_dispatch_intent:Function]
# @COMPLEXITY: 5
# @PURPOSE: 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).
async def _dispatch_intent(intent: Dict[str, Any], current_user: User, task_manager: TaskManager, config_manager: ConfigManager, db: Session) -> Tuple[str, Optional[str], 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')
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')
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)
provider_model = provider.default_model if provider else ''
if not is_multimodal_model(provider_model, provider.provider_type if provider else None):
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')
# [/DEF:_dispatch_intent:Function]
# [/DEF:AssistantDispatch:Module]