feat(assistant): add multi-dialog UX, task-aware llm settings, and i18n cleanup

This commit is contained in:
2026-02-23 23:45:01 +03:00
parent ab1c87ffba
commit 7df7b4f98c
30 changed files with 1145 additions and 221 deletions

View File

@@ -27,6 +27,11 @@ from ...core.config_manager import ConfigManager
from ...core.database import get_db
from ...services.git_service import GitService
from ...services.llm_provider import LLMProviderService
from ...services.llm_prompt_templates import (
is_multimodal_model,
normalize_llm_settings,
resolve_bound_provider_id,
)
from ...core.superset_client import SupersetClient
from ...plugins.llm_analysis.service import LLMClient
from ...plugins.llm_analysis.models import LLMProviderType
@@ -449,7 +454,12 @@ def _is_production_env(token: Optional[str], config_manager: ConfigManager) -> b
# @PURPOSE: Resolve provider token to provider id with active/default fallback.
# @PRE: db session can load provider list through LLMProviderService.
# @POST: Returns provider id or None when no providers configured.
def _resolve_provider_id(provider_token: Optional[str], db: Session) -> Optional[str]:
def _resolve_provider_id(
provider_token: Optional[str],
db: Session,
config_manager: Optional[ConfigManager] = None,
task_key: Optional[str] = None,
) -> Optional[str]:
service = LLMProviderService(db)
providers = service.get_all_providers()
if not providers:
@@ -461,6 +471,15 @@ def _resolve_provider_id(provider_token: Optional[str], db: Session) -> Optional
if p.id.lower() == needle or p.name.lower() == needle:
return p.id
if config_manager and task_key:
try:
llm_settings = config_manager.get_config().settings.llm
bound_provider_id = resolve_bound_provider_id(llm_settings, task_key)
if bound_provider_id and any(p.id == bound_provider_id for p in providers):
return bound_provider_id
except Exception:
pass
active = next((p for p in providers if p.is_active), None)
return active.id if active else providers[0].id
# [/DEF:_resolve_provider_id:Function]
@@ -537,6 +556,27 @@ def _parse_command(message: str, config_manager: ConfigManager) -> Dict[str, Any
text = message.strip()
lower = text.lower()
if any(
phrase in lower
for phrase in [
"что ты умеешь",
"что умеешь",
"что ты можешь",
"help",
"помощь",
"доступные команды",
"какие команды",
]
):
return {
"domain": "assistant",
"operation": "show_capabilities",
"entities": {},
"confidence": 0.98,
"risk_level": "safe",
"requires_confirmation": False,
}
dashboard_id = _extract_id(lower, [r"(?:дашборд\w*|dashboard)\s*(?:id\s*)?(\d+)"])
dashboard_ref = _extract_id(
lower,
@@ -721,10 +761,26 @@ def _build_tool_catalog(current_user: User, config_manager: ConfigManager, db: S
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",
@@ -785,7 +841,7 @@ def _build_tool_catalog(current_user: User, config_manager: ConfigManager, db: S
"description": "Run LLM dashboard validation",
"required_entities": ["dashboard_id"],
"optional_entities": ["dashboard_ref", "environment", "provider"],
"defaults": {"environment": default_env_id, "provider": fallback_provider},
"defaults": {"environment": default_env_id, "provider": validation_provider},
"risk_level": "guarded",
"requires_confirmation": False,
},
@@ -795,7 +851,7 @@ def _build_tool_catalog(current_user: User, config_manager: ConfigManager, db: S
"description": "Generate dataset documentation via LLM",
"required_entities": ["dataset_id"],
"optional_entities": ["environment", "provider"],
"defaults": {"environment": default_env_id, "provider": fallback_provider},
"defaults": {"environment": default_env_id, "provider": documentation_provider},
"risk_level": "guarded",
"requires_confirmation": False,
},
@@ -867,9 +923,13 @@ async def _plan_intent_with_llm(
if not tools:
return None
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
planner_provider_token = llm_settings.get("assistant_planner_provider")
planner_model_override = llm_settings.get("assistant_planner_model")
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
provider = next((p for p in providers if p.is_active), None)
provider_id = _resolve_provider_id(planner_provider_token, db)
provider = next((p for p in providers if p.id == provider_id), None)
if not provider:
return None
api_key = llm_service.get_decrypted_api_key(provider.id)
@@ -880,7 +940,7 @@ async def _plan_intent_with_llm(
provider_type=LLMProviderType(provider.provider_type),
api_key=api_key,
base_url=provider.base_url,
default_model=provider.default_model,
default_model=planner_model_override or provider.default_model,
)
system_instruction = (
@@ -983,6 +1043,29 @@ async def _dispatch_intent(
operation = intent.get("operation")
entities = intent.get("entities", {})
if operation == "show_capabilities":
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": "Статус: проверка задачи",
}
available = [labels[t["operation"]] for t in tools_catalog if t["operation"] in labels]
if not available:
return "Сейчас нет доступных для вас операций ассистента.", None, []
commands = "\n".join(f"- {item}" for item in available)
text = (
"Вот что я могу сделать для вас:\n"
f"{commands}\n\n"
"Пример: `запусти миграцию с dev на prod для дашборда 42`."
)
return text, None, []
if operation == "get_task_status":
_check_any_permission(current_user, [("tasks", "READ")])
task_id = entities.get("task_id")
@@ -1111,12 +1194,27 @@ async def _dispatch_intent(
env_id,
config_manager,
)
provider_id = _resolve_provider_id(entities.get("provider"), db)
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):
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",
@@ -1140,7 +1238,12 @@ async def _dispatch_intent(
_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)
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")
@@ -1301,6 +1404,7 @@ async def send_message(
is_clarification_error = exc.status_code in (400, 422) and (
detail_text.lower().startswith("missing")
or "укажите" in detail_text.lower()
or "выберите" in detail_text.lower()
)
if exc.status_code == status.HTTP_403_FORBIDDEN:
state = "denied"