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

@@ -218,6 +218,29 @@ def test_unknown_command_returns_needs_clarification():
# [/DEF:test_unknown_command_returns_needs_clarification:Function]
# [DEF:test_capabilities_question_returns_successful_help:Function]
# @PURPOSE: Capability query should return deterministic help response, not clarification.
# @PRE: User sends natural-language "what can you do" style query.
# @POST: Response is successful and includes capabilities summary.
def test_capabilities_question_returns_successful_help():
_clear_assistant_state()
response = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(message="Что ты умеешь?"),
current_user=_admin_user(),
task_manager=_FakeTaskManager(),
config_manager=_FakeConfigManager(),
db=_FakeDb(),
)
)
assert response.state == "success"
assert "Вот что я могу сделать" in response.text
assert "Миграции" in response.text or "Git" in response.text
# [/DEF:test_capabilities_question_returns_successful_help:Function]
# [DEF:test_non_admin_command_returns_denied:Function]
# @PURPOSE: Non-admin user must receive denied state for privileged command.
# @PRE: Limited principal executes privileged git branch command.

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"

View File

@@ -25,7 +25,11 @@ from src.api.routes.git_schemas import (
)
from src.services.git_service import GitService
from src.core.logger import logger, belief_scope
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, normalize_llm_settings
from ...services.llm_prompt_templates import (
DEFAULT_LLM_PROMPTS,
normalize_llm_settings,
resolve_bound_provider_id,
)
router = APIRouter(tags=["git"])
git_service = GitService()
@@ -431,7 +435,11 @@ async def generate_commit_message(
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
provider = next((p for p in providers if p.is_active), None)
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
bound_provider_id = resolve_bound_provider_id(llm_settings, "git_commit")
provider = next((p for p in providers if p.id == bound_provider_id), None)
if not provider:
provider = next((p for p in providers if p.is_active), None)
if not provider:
raise HTTPException(status_code=400, detail="No active LLM provider found")
@@ -447,7 +455,6 @@ async def generate_commit_message(
# 4. Generate Message
from ...plugins.git.llm_extension import GitLLMExtension
extension = GitLLMExtension(client)
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
git_prompt = llm_settings["prompts"].get(
"git_commit_prompt",
DEFAULT_LLM_PROMPTS["git_commit_prompt"],

View File

@@ -9,9 +9,15 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel
from ...core.logger import belief_scope
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...core.task_manager.models import LogFilter, LogStats
from ...dependencies import get_task_manager, has_permission, get_current_user
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...core.task_manager.models import LogFilter, LogStats
from ...dependencies import get_task_manager, has_permission, get_current_user, get_config_manager
from ...core.config_manager import ConfigManager
from ...services.llm_prompt_templates import (
is_multimodal_model,
normalize_llm_settings,
resolve_bound_provider_id,
)
router = APIRouter()
@@ -39,32 +45,50 @@ class ResumeTaskRequest(BaseModel):
# @PRE: plugin_id must exist and params must be valid for that plugin.
# @POST: A new task is created and started.
# @RETURN: Task - The created task instance.
async def create_task(
request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager),
current_user = Depends(get_current_user)
):
async def create_task(
request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager),
current_user = Depends(get_current_user),
config_manager: ConfigManager = Depends(get_config_manager),
):
# Dynamic permission check based on plugin_id
has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user)
"""
Create and start a new task for a given plugin.
"""
with belief_scope("create_task"):
try:
# Special handling for validation task to include provider config
if request.plugin_id == "llm_dashboard_validation":
from ...core.database import SessionLocal
from ...services.llm_provider import LLMProviderService
db = SessionLocal()
try:
llm_service = LLMProviderService(db)
provider_id = request.params.get("provider_id")
if provider_id:
db_provider = llm_service.get_provider(provider_id)
if not db_provider:
raise ValueError(f"LLM Provider {provider_id} not found")
finally:
db.close()
try:
# Special handling for LLM tasks to resolve provider config by task binding.
if request.plugin_id in {"llm_dashboard_validation", "llm_documentation"}:
from ...core.database import SessionLocal
from ...services.llm_provider import LLMProviderService
db = SessionLocal()
try:
llm_service = LLMProviderService(db)
provider_id = request.params.get("provider_id")
if not provider_id:
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
binding_key = "dashboard_validation" if request.plugin_id == "llm_dashboard_validation" else "documentation"
provider_id = resolve_bound_provider_id(llm_settings, binding_key)
if provider_id:
request.params["provider_id"] = provider_id
if not provider_id:
providers = llm_service.get_all_providers()
active_provider = next((p for p in providers if p.is_active), None)
if active_provider:
provider_id = active_provider.id
request.params["provider_id"] = provider_id
if provider_id:
db_provider = llm_service.get_provider(provider_id)
if not db_provider:
raise ValueError(f"LLM Provider {provider_id} not found")
if request.plugin_id == "llm_dashboard_validation" and not is_multimodal_model(db_provider.default_model):
raise ValueError(
"Selected provider model is not multimodal for dashboard validation"
)
finally:
db.close()
task = await task_manager.create_task(
plugin_id=request.plugin_id,