diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py index 22420a3..4f991d2 100755 --- a/backend/src/api/routes/__init__.py +++ b/backend/src/api/routes/__init__.py @@ -1,10 +1,23 @@ -# Lazy loading of route modules to avoid import issues in tests -# This allows tests to import routes without triggering all module imports - -__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports'] - -def __getattr__(name): - if name in __all__: - import importlib - return importlib.import_module(f".{name}", __name__) - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +# [DEF:backend.src.api.routes.__init__:Module] +# @TIER: STANDARD +# @SEMANTICS: routes, lazy-import, module-registry +# @PURPOSE: Provide lazy route module loading to avoid heavyweight imports during tests. +# @LAYER: API +# @RELATION: DEPENDS_ON -> importlib +# @INVARIANT: Only names listed in __all__ are importable via __getattr__. + +__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports', 'assistant'] + + +# [DEF:__getattr__:Function] +# @TIER: TRIVIAL +# @PURPOSE: Lazily import route module by attribute name. +# @PRE: name is module candidate exposed in __all__. +# @POST: Returns imported submodule or raises AttributeError. +def __getattr__(name): + if name in __all__: + import importlib + return importlib.import_module(f".{name}", __name__) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +# [/DEF:__getattr__:Function] +# [/DEF:backend.src.api.routes.__init__:Module] diff --git a/backend/src/api/routes/__tests__/test_assistant_api.py b/backend/src/api/routes/__tests__/test_assistant_api.py new file mode 100644 index 0000000..25e9514 --- /dev/null +++ b/backend/src/api/routes/__tests__/test_assistant_api.py @@ -0,0 +1,371 @@ +# [DEF:backend.src.api.routes.__tests__.test_assistant_api:Module] +# @TIER: STANDARD +# @SEMANTICS: tests, assistant, api, confirmation, status +# @PURPOSE: Validate assistant API endpoint logic via direct async handler invocation. +# @LAYER: UI (API Tests) +# @RELATION: DEPENDS_ON -> backend.src.api.routes.assistant +# @INVARIANT: Every test clears assistant in-memory state before execution. + +import os +import asyncio +from types import SimpleNamespace + +# Force isolated sqlite databases for test module before dependencies import. +os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_api.db") +os.environ.setdefault("TASKS_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_tasks.db") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_auth.db") + +from src.api.routes import assistant as assistant_module +from src.models.assistant import ( + AssistantAuditRecord, + AssistantConfirmationRecord, + AssistantMessageRecord, +) + + +# [DEF:_run_async:Function] +# @TIER: TRIVIAL +# @PURPOSE: Execute async endpoint handler in synchronous test context. +# @PRE: coroutine is awaitable endpoint invocation. +# @POST: Returns coroutine result or raises propagated exception. +def _run_async(coroutine): + return asyncio.run(coroutine) + + +# [/DEF:_run_async:Function] +# [DEF:_FakeTask:Class] +# @TIER: TRIVIAL +# @PURPOSE: Lightweight task stub used by assistant API tests. +class _FakeTask: + def __init__(self, task_id: str, status: str = "RUNNING", user_id: str = "u-admin"): + self.id = task_id + self.status = status + self.user_id = user_id + + +# [/DEF:_FakeTask:Class] +# [DEF:_FakeTaskManager:Class] +# @TIER: TRIVIAL +# @PURPOSE: Minimal async-compatible TaskManager fixture for deterministic test flows. +class _FakeTaskManager: + def __init__(self): + self._created = [] + + async def create_task(self, plugin_id, params, user_id=None): + task_id = f"task-{len(self._created) + 1}" + task = _FakeTask(task_id=task_id, status="RUNNING", user_id=user_id) + self._created.append((plugin_id, params, user_id, task)) + return task + + def get_task(self, task_id): + for _, _, _, task in self._created: + if task.id == task_id: + return task + return None + + def get_tasks(self, limit=20, offset=0): + return [x[3] for x in self._created][offset : offset + limit] + + +# [/DEF:_FakeTaskManager:Class] +# [DEF:_FakeConfigManager:Class] +# @TIER: TRIVIAL +# @PURPOSE: Environment config fixture with dev/prod aliases for parser tests. +class _FakeConfigManager: + def get_environments(self): + return [ + SimpleNamespace(id="dev", name="Development"), + SimpleNamespace(id="prod", name="Production"), + ] + + +# [/DEF:_FakeConfigManager:Class] +# [DEF:_admin_user:Function] +# @TIER: TRIVIAL +# @PURPOSE: Build admin principal fixture. +# @PRE: Test harness requires authenticated admin-like principal object. +# @POST: Returns user stub with Admin role. +def _admin_user(): + role = SimpleNamespace(name="Admin", permissions=[]) + return SimpleNamespace(id="u-admin", username="admin", roles=[role]) + + +# [/DEF:_admin_user:Function] +# [DEF:_limited_user:Function] +# @TIER: TRIVIAL +# @PURPOSE: Build non-admin principal fixture. +# @PRE: Test harness requires restricted principal for deny scenarios. +# @POST: Returns user stub without admin privileges. +def _limited_user(): + role = SimpleNamespace(name="Operator", permissions=[]) + return SimpleNamespace(id="u-limited", username="limited", roles=[role]) + + +# [/DEF:_limited_user:Function] +# [DEF:_FakeQuery:Class] +# @TIER: TRIVIAL +# @PURPOSE: Minimal chainable query object for fake SQLAlchemy-like DB behavior in tests. +class _FakeQuery: + def __init__(self, rows): + self._rows = list(rows) + + def filter(self, *args, **kwargs): + return self + + def order_by(self, *args, **kwargs): + return self + + def first(self): + return self._rows[0] if self._rows else None + + def all(self): + return list(self._rows) + + def count(self): + return len(self._rows) + + def offset(self, offset): + self._rows = self._rows[offset:] + return self + + def limit(self, limit): + self._rows = self._rows[:limit] + return self + + +# [/DEF:_FakeQuery:Class] +# [DEF:_FakeDb:Class] +# @TIER: TRIVIAL +# @PURPOSE: In-memory fake database implementing subset of Session interface used by assistant routes. +class _FakeDb: + def __init__(self): + self._messages = [] + self._confirmations = [] + self._audit = [] + + def add(self, row): + table = getattr(row, "__tablename__", "") + if table == "assistant_messages": + self._messages.append(row) + return + if table == "assistant_confirmations": + self._confirmations.append(row) + return + if table == "assistant_audit": + self._audit.append(row) + + def merge(self, row): + table = getattr(row, "__tablename__", "") + if table != "assistant_confirmations": + self.add(row) + return row + + for i, existing in enumerate(self._confirmations): + if getattr(existing, "id", None) == getattr(row, "id", None): + self._confirmations[i] = row + return row + self._confirmations.append(row) + return row + + def query(self, model): + if model is AssistantMessageRecord: + return _FakeQuery(self._messages) + if model is AssistantConfirmationRecord: + return _FakeQuery(self._confirmations) + if model is AssistantAuditRecord: + return _FakeQuery(self._audit) + return _FakeQuery([]) + + def commit(self): + return None + + def rollback(self): + return None + + +# [/DEF:_FakeDb:Class] +# [DEF:_clear_assistant_state:Function] +# @TIER: TRIVIAL +# @PURPOSE: Reset in-memory assistant registries for isolation between tests. +# @PRE: Assistant module globals may contain residues from previous test runs. +# @POST: In-memory conversation/confirmation/audit dictionaries are empty. +def _clear_assistant_state(): + assistant_module.CONVERSATIONS.clear() + assistant_module.USER_ACTIVE_CONVERSATION.clear() + assistant_module.CONFIRMATIONS.clear() + assistant_module.ASSISTANT_AUDIT.clear() + + +# [/DEF:_clear_assistant_state:Function] +# [DEF:test_unknown_command_returns_needs_clarification:Function] +# @PURPOSE: Unknown command should return clarification state and unknown intent. +# @PRE: Fake dependencies provide admin user and deterministic task/config/db services. +# @POST: Response state is needs_clarification and no execution side-effect occurs. +def test_unknown_command_returns_needs_clarification(): + _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 == "needs_clarification" + assert response.intent["domain"] == "unknown" + + +# [/DEF:test_unknown_command_returns_needs_clarification: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. +# @POST: Response state is denied and operation is not executed. +def test_non_admin_command_returns_denied(): + _clear_assistant_state() + response = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message="создай ветку feature/test для дашборда 12" + ), + current_user=_limited_user(), + task_manager=_FakeTaskManager(), + config_manager=_FakeConfigManager(), + db=_FakeDb(), + ) + ) + assert response.state == "denied" + + +# [/DEF:test_non_admin_command_returns_denied:Function] +# [DEF:test_migration_to_prod_requires_confirmation_and_can_be_confirmed:Function] +# @PURPOSE: Migration to prod must require confirmation and then start task after explicit confirm. +# @PRE: Admin principal submits dangerous migration command. +# @POST: Confirmation endpoint transitions flow to started state with task id. +def test_migration_to_prod_requires_confirmation_and_can_be_confirmed(): + _clear_assistant_state() + task_manager = _FakeTaskManager() + db = _FakeDb() + + first = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message="запусти миграцию с dev на prod для дашборда 12" + ), + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + assert first.state == "needs_confirmation" + assert first.confirmation_id + + second = _run_async( + assistant_module.confirm_operation( + confirmation_id=first.confirmation_id, + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + assert second.state == "started" + assert second.task_id.startswith("task-") + + +# [/DEF:test_migration_to_prod_requires_confirmation_and_can_be_confirmed:Function] +# [DEF:test_status_query_returns_task_status:Function] +# @PURPOSE: Task status command must surface current status text for existing task id. +# @PRE: At least one task exists after confirmed operation. +# @POST: Status query returns started/success and includes referenced task id. +def test_status_query_returns_task_status(): + _clear_assistant_state() + task_manager = _FakeTaskManager() + db = _FakeDb() + + start = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message="запусти миграцию с dev на prod для дашборда 10" + ), + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + confirm = _run_async( + assistant_module.confirm_operation( + confirmation_id=start.confirmation_id, + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + task_id = confirm.task_id + + status_resp = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message=f"проверь статус задачи {task_id}" + ), + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + assert status_resp.state in {"started", "success"} + assert task_id in status_resp.text + + +# [/DEF:test_status_query_returns_task_status:Function] +# [DEF:test_status_query_without_task_id_returns_latest_user_task:Function] +# @PURPOSE: Status command without explicit task_id should resolve to latest task for current user. +# @PRE: User has at least one created task in task manager history. +# @POST: Response references latest task status without explicit task id in command. +def test_status_query_without_task_id_returns_latest_user_task(): + _clear_assistant_state() + task_manager = _FakeTaskManager() + db = _FakeDb() + + start = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message="запусти миграцию с dev на prod для дашборда 33" + ), + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + _run_async( + assistant_module.confirm_operation( + confirmation_id=start.confirmation_id, + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + + status_resp = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message="покажи статус последней задачи" + ), + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + assert status_resp.state in {"started", "success"} + assert "Последняя задача:" in status_resp.text + + +# [/DEF:test_status_query_without_task_id_returns_latest_user_task:Function] +# [/DEF:backend.src.api.routes.__tests__.test_assistant_api:Module] diff --git a/backend/src/api/routes/__tests__/test_assistant_authz.py b/backend/src/api/routes/__tests__/test_assistant_authz.py new file mode 100644 index 0000000..19e64a7 --- /dev/null +++ b/backend/src/api/routes/__tests__/test_assistant_authz.py @@ -0,0 +1,306 @@ +# [DEF:backend.src.api.routes.__tests__.test_assistant_authz:Module] +# @TIER: STANDARD +# @SEMANTICS: tests, assistant, authz, confirmation, rbac +# @PURPOSE: Verify assistant confirmation ownership, expiration, and deny behavior for restricted users. +# @LAYER: UI (API Tests) +# @RELATION: DEPENDS_ON -> backend.src.api.routes.assistant +# @INVARIANT: Security-sensitive flows fail closed for unauthorized actors. + +import os +import asyncio +from datetime import datetime, timedelta +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +# Force isolated sqlite databases for test module before dependencies import. +os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz.db") +os.environ.setdefault("TASKS_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_tasks.db") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_authz_auth.db") + +from src.api.routes import assistant as assistant_module +from src.models.assistant import ( + AssistantAuditRecord, + AssistantConfirmationRecord, + AssistantMessageRecord, +) + + +# [DEF:_run_async:Function] +# @TIER: TRIVIAL +# @PURPOSE: Execute async endpoint handler in synchronous test context. +# @PRE: coroutine is awaitable endpoint invocation. +# @POST: Returns coroutine result or raises propagated exception. +def _run_async(coroutine): + return asyncio.run(coroutine) + + +# [/DEF:_run_async:Function] +# [DEF:_FakeTask:Class] +# @TIER: TRIVIAL +# @PURPOSE: Lightweight task model used for assistant authz tests. +class _FakeTask: + def __init__(self, task_id: str, status: str = "RUNNING", user_id: str = "u-admin"): + self.id = task_id + self.status = status + self.user_id = user_id + + +# [/DEF:_FakeTask:Class] +# [DEF:_FakeTaskManager:Class] +# @TIER: TRIVIAL +# @PURPOSE: Minimal task manager for deterministic operation creation and lookup. +class _FakeTaskManager: + def __init__(self): + self._created = [] + + async def create_task(self, plugin_id, params, user_id=None): + task_id = f"task-{len(self._created) + 1}" + task = _FakeTask(task_id=task_id, status="RUNNING", user_id=user_id) + self._created.append((plugin_id, params, user_id, task)) + return task + + def get_task(self, task_id): + for _, _, _, task in self._created: + if task.id == task_id: + return task + return None + + def get_tasks(self, limit=20, offset=0): + return [x[3] for x in self._created][offset : offset + limit] + + +# [/DEF:_FakeTaskManager:Class] +# [DEF:_FakeConfigManager:Class] +# @TIER: TRIVIAL +# @PURPOSE: Provide deterministic environment aliases required by intent parsing. +class _FakeConfigManager: + def get_environments(self): + return [ + SimpleNamespace(id="dev", name="Development"), + SimpleNamespace(id="prod", name="Production"), + ] + + +# [/DEF:_FakeConfigManager:Class] +# [DEF:_admin_user:Function] +# @TIER: TRIVIAL +# @PURPOSE: Build admin principal fixture. +# @PRE: Test requires privileged principal for risky operations. +# @POST: Returns admin-like user stub with Admin role. +def _admin_user(): + role = SimpleNamespace(name="Admin", permissions=[]) + return SimpleNamespace(id="u-admin", username="admin", roles=[role]) + + +# [/DEF:_admin_user:Function] +# [DEF:_other_admin_user:Function] +# @TIER: TRIVIAL +# @PURPOSE: Build second admin principal fixture for ownership tests. +# @PRE: Ownership mismatch scenario needs distinct authenticated actor. +# @POST: Returns alternate admin-like user stub. +def _other_admin_user(): + role = SimpleNamespace(name="Admin", permissions=[]) + return SimpleNamespace(id="u-admin-2", username="admin2", roles=[role]) + + +# [/DEF:_other_admin_user:Function] +# [DEF:_limited_user:Function] +# @TIER: TRIVIAL +# @PURPOSE: Build limited principal without required assistant execution privileges. +# @PRE: Permission denial scenario needs non-admin actor. +# @POST: Returns restricted user stub. +def _limited_user(): + role = SimpleNamespace(name="Operator", permissions=[]) + return SimpleNamespace(id="u-limited", username="limited", roles=[role]) + + +# [/DEF:_limited_user:Function] +# [DEF:_FakeQuery:Class] +# @TIER: TRIVIAL +# @PURPOSE: Minimal chainable query object for fake DB interactions. +class _FakeQuery: + def __init__(self, rows): + self._rows = list(rows) + + def filter(self, *args, **kwargs): + return self + + def order_by(self, *args, **kwargs): + return self + + def first(self): + return self._rows[0] if self._rows else None + + def all(self): + return list(self._rows) + + def limit(self, limit): + self._rows = self._rows[:limit] + return self + + def offset(self, offset): + self._rows = self._rows[offset:] + return self + + def count(self): + return len(self._rows) + + +# [/DEF:_FakeQuery:Class] +# [DEF:_FakeDb:Class] +# @TIER: TRIVIAL +# @PURPOSE: In-memory session substitute for assistant route persistence calls. +class _FakeDb: + def __init__(self): + self._messages = [] + self._confirmations = [] + self._audit = [] + + def add(self, row): + table = getattr(row, "__tablename__", "") + if table == "assistant_messages": + self._messages.append(row) + elif table == "assistant_confirmations": + self._confirmations.append(row) + elif table == "assistant_audit": + self._audit.append(row) + + def merge(self, row): + if getattr(row, "__tablename__", "") != "assistant_confirmations": + self.add(row) + return row + + for i, existing in enumerate(self._confirmations): + if getattr(existing, "id", None) == getattr(row, "id", None): + self._confirmations[i] = row + return row + self._confirmations.append(row) + return row + + def query(self, model): + if model is AssistantMessageRecord: + return _FakeQuery(self._messages) + if model is AssistantConfirmationRecord: + return _FakeQuery(self._confirmations) + if model is AssistantAuditRecord: + return _FakeQuery(self._audit) + return _FakeQuery([]) + + def commit(self): + return None + + def rollback(self): + return None + + +# [/DEF:_FakeDb:Class] +# [DEF:_clear_assistant_state:Function] +# @TIER: TRIVIAL +# @PURPOSE: Reset assistant process-local state between test cases. +# @PRE: Assistant globals may contain state from prior tests. +# @POST: Assistant in-memory state dictionaries are cleared. +def _clear_assistant_state(): + assistant_module.CONVERSATIONS.clear() + assistant_module.USER_ACTIVE_CONVERSATION.clear() + assistant_module.CONFIRMATIONS.clear() + assistant_module.ASSISTANT_AUDIT.clear() + + +# [/DEF:_clear_assistant_state:Function] +# [DEF:test_confirmation_owner_mismatch_returns_403:Function] +# @PURPOSE: Confirm endpoint should reject requests from user that does not own the confirmation token. +# @PRE: Confirmation token is created by first admin actor. +# @POST: Second actor receives 403 on confirm operation. +def test_confirmation_owner_mismatch_returns_403(): + _clear_assistant_state() + task_manager = _FakeTaskManager() + db = _FakeDb() + + create = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message="запусти миграцию с dev на prod для дашборда 18" + ), + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + assert create.state == "needs_confirmation" + + with pytest.raises(HTTPException) as exc: + _run_async( + assistant_module.confirm_operation( + confirmation_id=create.confirmation_id, + current_user=_other_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + assert exc.value.status_code == 403 + + +# [/DEF:test_confirmation_owner_mismatch_returns_403:Function] +# [DEF:test_expired_confirmation_cannot_be_confirmed:Function] +# @PURPOSE: Expired confirmation token should be rejected and not create task. +# @PRE: Confirmation token exists and is manually expired before confirm request. +# @POST: Confirm endpoint raises 400 and no task is created. +def test_expired_confirmation_cannot_be_confirmed(): + _clear_assistant_state() + task_manager = _FakeTaskManager() + db = _FakeDb() + + create = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message="запусти миграцию с dev на prod для дашборда 19" + ), + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + assistant_module.CONFIRMATIONS[create.confirmation_id].expires_at = datetime.utcnow() - timedelta(minutes=1) + + with pytest.raises(HTTPException) as exc: + _run_async( + assistant_module.confirm_operation( + confirmation_id=create.confirmation_id, + current_user=_admin_user(), + task_manager=task_manager, + config_manager=_FakeConfigManager(), + db=db, + ) + ) + assert exc.value.status_code == 400 + assert task_manager.get_tasks(limit=10, offset=0) == [] + + +# [/DEF:test_expired_confirmation_cannot_be_confirmed:Function] +# [DEF:test_limited_user_cannot_launch_restricted_operation:Function] +# @PURPOSE: Limited user should receive denied state for privileged operation. +# @PRE: Restricted user attempts dangerous deploy command. +# @POST: Assistant returns denied state and does not execute operation. +def test_limited_user_cannot_launch_restricted_operation(): + _clear_assistant_state() + response = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message="задеплой дашборд 88 в production" + ), + current_user=_limited_user(), + task_manager=_FakeTaskManager(), + config_manager=_FakeConfigManager(), + db=_FakeDb(), + ) + ) + assert response.state == "denied" + + +# [/DEF:test_limited_user_cannot_launch_restricted_operation:Function] +# [/DEF:backend.src.api.routes.__tests__.test_assistant_authz:Module] diff --git a/backend/src/api/routes/assistant.py b/backend/src/api/routes/assistant.py new file mode 100644 index 0000000..dacad81 --- /dev/null +++ b/backend/src/api/routes/assistant.py @@ -0,0 +1,1130 @@ +# [DEF:backend.src.api.routes.assistant:Module] +# @TIER: STANDARD +# @SEMANTICS: api, assistant, chat, command, confirmation +# @PURPOSE: API routes for LLM assistant command parsing and safe execution orchestration. +# @LAYER: API +# @RELATION: DEPENDS_ON -> backend.src.core.task_manager +# @RELATION: DEPENDS_ON -> backend.src.models.assistant +# @INVARIANT: Risky operations are never executed without valid confirmation token. + +from __future__ import annotations + +import re +import uuid +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session +from sqlalchemy import desc + +from ...core.logger import belief_scope, logger +from ...core.task_manager import TaskManager +from ...dependencies import get_current_user, get_task_manager, get_config_manager, has_permission +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 ...schemas.auth import User +from ...models.assistant import ( + AssistantAuditRecord, + AssistantConfirmationRecord, + AssistantMessageRecord, +) + +router = APIRouter(tags=["Assistant"]) +git_service = GitService() + + +# [DEF:AssistantMessageRequest:Class] +# @TIER: TRIVIAL +# @PURPOSE: Input payload for assistant message endpoint. +# @PRE: message length is within accepted bounds. +# @POST: Request object provides message text and optional conversation binding. +class AssistantMessageRequest(BaseModel): + conversation_id: Optional[str] = None + message: str = Field(..., min_length=1, max_length=4000) +# [/DEF:AssistantMessageRequest:Class] + + +# [DEF:AssistantAction:Class] +# @TIER: TRIVIAL +# @PURPOSE: UI action descriptor returned with assistant responses. +# @PRE: type and label are provided by orchestration logic. +# @POST: Action can be rendered as button on frontend. +class AssistantAction(BaseModel): + type: str + label: str + target: Optional[str] = None +# [/DEF:AssistantAction:Class] + + +# [DEF:AssistantMessageResponse:Class] +# @TIER: STANDARD +# @PURPOSE: Output payload contract for assistant interaction endpoints. +# @PRE: Response includes deterministic state and text. +# @POST: Payload may include task_id/confirmation_id/actions for UI follow-up. +class AssistantMessageResponse(BaseModel): + conversation_id: str + response_id: str + state: str + text: str + intent: Optional[Dict[str, Any]] = None + confirmation_id: Optional[str] = None + task_id: Optional[str] = None + actions: List[AssistantAction] = Field(default_factory=list) + created_at: datetime +# [/DEF:AssistantMessageResponse:Class] + + +# [DEF:ConfirmationRecord:Class] +# @TIER: STANDARD +# @PURPOSE: In-memory confirmation token model for risky operation dispatch. +# @PRE: intent/dispatch/user_id are populated at confirmation request time. +# @POST: Record tracks lifecycle state and expiry timestamp. +class ConfirmationRecord(BaseModel): + id: str + user_id: str + conversation_id: str + intent: Dict[str, Any] + dispatch: Dict[str, Any] + expires_at: datetime + state: str = "pending" + created_at: datetime +# [/DEF:ConfirmationRecord:Class] + + +CONVERSATIONS: Dict[Tuple[str, str], List[Dict[str, Any]]] = {} +USER_ACTIVE_CONVERSATION: Dict[str, str] = {} +CONFIRMATIONS: Dict[str, ConfirmationRecord] = {} +ASSISTANT_AUDIT: Dict[str, List[Dict[str, Any]]] = {} + + +# [DEF:_append_history:Function] +# @PURPOSE: Append conversation message to in-memory history buffer. +# @PRE: user_id and conversation_id identify target conversation bucket. +# @POST: Message entry is appended to CONVERSATIONS key list. +def _append_history( + user_id: str, + conversation_id: str, + role: str, + text: str, + state: Optional[str] = None, + task_id: Optional[str] = None, + confirmation_id: Optional[str] = None, +): + key = (user_id, conversation_id) + if key not in CONVERSATIONS: + CONVERSATIONS[key] = [] + CONVERSATIONS[key].append( + { + "message_id": str(uuid.uuid4()), + "conversation_id": conversation_id, + "role": role, + "text": text, + "state": state, + "task_id": task_id, + "confirmation_id": confirmation_id, + "created_at": datetime.utcnow(), + } + ) +# [/DEF:_append_history:Function] + + +# [DEF:_persist_message:Function] +# @PURPOSE: Persist assistant/user message record to database. +# @PRE: db session is writable and message payload is serializable. +# @POST: Message row is committed or persistence failure is logged. +def _persist_message( + db: Session, + user_id: str, + conversation_id: str, + role: str, + text: str, + state: Optional[str] = None, + task_id: Optional[str] = None, + confirmation_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, +): + try: + row = AssistantMessageRecord( + id=str(uuid.uuid4()), + user_id=user_id, + conversation_id=conversation_id, + role=role, + text=text, + state=state, + task_id=task_id, + confirmation_id=confirmation_id, + payload=metadata, + ) + db.add(row) + db.commit() + except Exception as exc: + db.rollback() + logger.warning(f"[assistant.message][persist_failed] {exc}") +# [/DEF:_persist_message:Function] + + +# [DEF:_audit:Function] +# @PURPOSE: Append in-memory audit record for assistant decision trace. +# @PRE: payload describes decision/outcome fields. +# @POST: ASSISTANT_AUDIT list for user contains new timestamped entry. +def _audit(user_id: str, payload: Dict[str, Any]): + if user_id not in ASSISTANT_AUDIT: + ASSISTANT_AUDIT[user_id] = [] + ASSISTANT_AUDIT[user_id].append({**payload, "created_at": datetime.utcnow().isoformat()}) + logger.info(f"[assistant.audit] {payload}") +# [/DEF:_audit:Function] + + +# [DEF:_persist_audit:Function] +# @PURPOSE: Persist structured assistant audit payload in database. +# @PRE: db session is writable and payload is JSON-serializable. +# @POST: Audit row is committed or failure is logged with rollback. +def _persist_audit(db: Session, user_id: str, payload: Dict[str, Any], conversation_id: Optional[str]): + try: + row = AssistantAuditRecord( + id=str(uuid.uuid4()), + user_id=user_id, + conversation_id=conversation_id, + decision=payload.get("decision"), + task_id=payload.get("task_id"), + message=payload.get("message"), + payload=payload, + ) + db.add(row) + db.commit() + except Exception as exc: + db.rollback() + logger.warning(f"[assistant.audit][persist_failed] {exc}") +# [/DEF:_persist_audit:Function] + + +# [DEF:_persist_confirmation:Function] +# @PURPOSE: Persist confirmation token record to database. +# @PRE: record contains id/user/intent/dispatch/expiry fields. +# @POST: Confirmation row exists in persistent storage. +def _persist_confirmation(db: Session, record: ConfirmationRecord): + try: + row = AssistantConfirmationRecord( + id=record.id, + user_id=record.user_id, + conversation_id=record.conversation_id, + state=record.state, + intent=record.intent, + dispatch=record.dispatch, + expires_at=record.expires_at, + created_at=record.created_at, + consumed_at=None, + ) + db.merge(row) + db.commit() + except Exception as exc: + db.rollback() + logger.warning(f"[assistant.confirmation][persist_failed] {exc}") +# [/DEF:_persist_confirmation:Function] + + +# [DEF:_update_confirmation_state:Function] +# @PURPOSE: Update persistent confirmation token lifecycle state. +# @PRE: confirmation_id references existing row. +# @POST: State and consumed_at fields are updated when applicable. +def _update_confirmation_state(db: Session, confirmation_id: str, state: str): + try: + row = db.query(AssistantConfirmationRecord).filter(AssistantConfirmationRecord.id == confirmation_id).first() + if not row: + return + row.state = state + if state in {"consumed", "expired", "cancelled"}: + row.consumed_at = datetime.utcnow() + db.commit() + except Exception as exc: + db.rollback() + logger.warning(f"[assistant.confirmation][update_failed] {exc}") +# [/DEF:_update_confirmation_state:Function] + + +# [DEF:_load_confirmation_from_db:Function] +# @PURPOSE: Load confirmation token from database into in-memory model. +# @PRE: confirmation_id may or may not exist in storage. +# @POST: Returns ConfirmationRecord when found, otherwise None. +def _load_confirmation_from_db(db: Session, confirmation_id: str) -> Optional[ConfirmationRecord]: + row = ( + db.query(AssistantConfirmationRecord) + .filter(AssistantConfirmationRecord.id == confirmation_id) + .first() + ) + if not row: + return None + return ConfirmationRecord( + id=row.id, + user_id=row.user_id, + conversation_id=row.conversation_id, + intent=row.intent or {}, + dispatch=row.dispatch or {}, + expires_at=row.expires_at, + state=row.state, + created_at=row.created_at, + ) +# [/DEF:_load_confirmation_from_db:Function] + + +# [DEF:_ensure_conversation:Function] +# @PURPOSE: Resolve active conversation id in memory or create a new one. +# @PRE: user_id identifies current actor. +# @POST: Returns stable conversation id and updates USER_ACTIVE_CONVERSATION. +def _ensure_conversation(user_id: str, conversation_id: Optional[str]) -> str: + if conversation_id: + USER_ACTIVE_CONVERSATION[user_id] = conversation_id + return conversation_id + + active = USER_ACTIVE_CONVERSATION.get(user_id) + if active: + return active + + new_id = str(uuid.uuid4()) + USER_ACTIVE_CONVERSATION[user_id] = new_id + return new_id +# [/DEF:_ensure_conversation:Function] + + +# [DEF:_resolve_or_create_conversation:Function] +# @PURPOSE: Resolve active conversation using explicit id, memory cache, or persisted history. +# @PRE: user_id and db session are available. +# @POST: Returns conversation id and updates USER_ACTIVE_CONVERSATION cache. +def _resolve_or_create_conversation(user_id: str, conversation_id: Optional[str], db: Session) -> str: + if conversation_id: + USER_ACTIVE_CONVERSATION[user_id] = conversation_id + return conversation_id + + active = USER_ACTIVE_CONVERSATION.get(user_id) + if active: + return active + + last_message = ( + db.query(AssistantMessageRecord) + .filter(AssistantMessageRecord.user_id == user_id) + .order_by(desc(AssistantMessageRecord.created_at)) + .first() + ) + if last_message: + USER_ACTIVE_CONVERSATION[user_id] = last_message.conversation_id + return last_message.conversation_id + + new_id = str(uuid.uuid4()) + USER_ACTIVE_CONVERSATION[user_id] = new_id + return new_id +# [/DEF:_resolve_or_create_conversation:Function] + + +# [DEF:_extract_id:Function] +# @PURPOSE: Extract first regex match group from text by ordered pattern list. +# @PRE: patterns contain at least one capture group. +# @POST: Returns first matched token or None. +def _extract_id(text: str, patterns: List[str]) -> Optional[str]: + for p in patterns: + m = re.search(p, text, flags=re.IGNORECASE) + if m: + return m.group(1) + return None +# [/DEF:_extract_id:Function] + + +# [DEF:_resolve_env_id:Function] +# @PURPOSE: Resolve environment identifier/name token to canonical environment id. +# @PRE: config_manager provides environment list. +# @POST: Returns matched environment id or None. +def _resolve_env_id(token: Optional[str], config_manager: ConfigManager) -> Optional[str]: + if not token: + return None + + normalized = token.strip().lower() + envs = config_manager.get_environments() + for env in envs: + if env.id.lower() == normalized or env.name.lower() == normalized: + return env.id + return None +# [/DEF:_resolve_env_id:Function] + + +# [DEF:_is_production_env:Function] +# @PURPOSE: Determine whether environment token resolves to production-like target. +# @PRE: config_manager provides environments or token text is provided. +# @POST: Returns True for production/prod synonyms, else False. +def _is_production_env(token: Optional[str], config_manager: ConfigManager) -> bool: + env_id = _resolve_env_id(token, config_manager) + if not env_id: + return (token or "").strip().lower() in {"prod", "production", "прод"} + + env = next((e for e in config_manager.get_environments() if e.id == env_id), None) + if not env: + return False + target = f"{env.id} {env.name}".lower() + return "prod" in target or "production" in target or "прод" in target +# [/DEF:_is_production_env:Function] + + +# [DEF:_resolve_provider_id:Function] +# @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]: + service = LLMProviderService(db) + providers = service.get_all_providers() + if not providers: + return None + + if provider_token: + needle = provider_token.strip().lower() + for p in providers: + if p.id.lower() == needle or p.name.lower() == needle: + return p.id + + 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] + + +# [DEF:_parse_command:Function] +# @PURPOSE: Deterministically parse RU/EN command text into intent payload. +# @PRE: message contains raw user text and config manager resolves environments. +# @POST: Returns intent dict with domain/operation/entities/confidence/risk fields. +def _parse_command(message: str, config_manager: ConfigManager) -> Dict[str, Any]: + text = message.strip() + lower = text.lower() + + dashboard_id = _extract_id(lower, [r"(?:дашборд\w*|dashboard)\s*(?:id\s*)?(\d+)"]) + dataset_id = _extract_id(lower, [r"(?:датасет\w*|dataset)\s*(?:id\s*)?(\d+)"]) + # Accept short and long task ids (e.g., task-1, task-abc123, UUIDs). + task_id = _extract_id(lower, [r"(task[-_a-z0-9]{1,}|[0-9a-f]{8}-[0-9a-f-]{27,})"]) + + # Status query + if any(k in lower for k in ["статус", "status", "state", "проверь задачу"]): + return { + "domain": "status", + "operation": "get_task_status", + "entities": {"task_id": task_id}, + "confidence": 0.92 if task_id else 0.66, + "risk_level": "safe", + "requires_confirmation": False, + } + + # Git branch create + if any(k in lower for k in ["ветк", "branch"]) and any(k in lower for k in ["созд", "сделай", "create"]): + branch = _extract_id(lower, [r"(?:ветк\w*|branch)\s+([a-z0-9._/-]+)"]) + return { + "domain": "git", + "operation": "create_branch", + "entities": { + "dashboard_id": int(dashboard_id) if dashboard_id else None, + "branch_name": branch, + }, + "confidence": 0.95 if branch and dashboard_id else 0.7, + "risk_level": "guarded", + "requires_confirmation": False, + } + + # Git commit + if any(k in lower for k in ["коммит", "commit"]): + quoted = re.search(r'"([^"]{3,120})"', text) + message_text = quoted.group(1) if quoted else "assistant: update dashboard changes" + return { + "domain": "git", + "operation": "commit_changes", + "entities": { + "dashboard_id": int(dashboard_id) if dashboard_id else None, + "message": message_text, + }, + "confidence": 0.9 if dashboard_id else 0.7, + "risk_level": "guarded", + "requires_confirmation": False, + } + + # Git deploy + if any(k in lower for k in ["деплой", "deploy", "разверн"]): + env_match = _extract_id(lower, [r"(?:в|to)\s+([a-z0-9_-]+)"]) + is_dangerous = _is_production_env(env_match, config_manager) + return { + "domain": "git", + "operation": "deploy_dashboard", + "entities": { + "dashboard_id": int(dashboard_id) if dashboard_id else None, + "environment": env_match, + }, + "confidence": 0.92 if dashboard_id and env_match else 0.7, + "risk_level": "dangerous" if is_dangerous else "guarded", + "requires_confirmation": is_dangerous, + } + + # Migration + if any(k in lower for k in ["миграц", "migration", "migrate"]): + src = _extract_id(lower, [r"(?:с|from)\s+([a-z0-9_-]+)"]) + tgt = _extract_id(lower, [r"(?:на|to)\s+([a-z0-9_-]+)"]) + is_dangerous = _is_production_env(tgt, config_manager) + return { + "domain": "migration", + "operation": "execute_migration", + "entities": { + "dashboard_id": int(dashboard_id) if dashboard_id else None, + "source_env": src, + "target_env": tgt, + }, + "confidence": 0.95 if dashboard_id and src and tgt else 0.72, + "risk_level": "dangerous" if is_dangerous else "guarded", + "requires_confirmation": is_dangerous, + } + + # Backup + if any(k in lower for k in ["бэкап", "backup", "резерв"]): + env_match = _extract_id(lower, [r"(?:в|for|из|from)\s+([a-z0-9_-]+)"]) + return { + "domain": "backup", + "operation": "run_backup", + "entities": { + "dashboard_id": int(dashboard_id) if dashboard_id else None, + "environment": env_match, + }, + "confidence": 0.9 if env_match else 0.7, + "risk_level": "guarded", + "requires_confirmation": False, + } + + # LLM validation + if any(k in lower for k in ["валидац", "validate", "провер"]): + env_match = _extract_id(lower, [r"(?:в|for|env|окружени[ея])\s+([a-z0-9_-]+)"]) + provider_match = _extract_id(lower, [r"(?:provider|провайдер)\s+([a-z0-9_-]+)"]) + return { + "domain": "llm", + "operation": "run_llm_validation", + "entities": { + "dashboard_id": int(dashboard_id) if dashboard_id else None, + "environment": env_match, + "provider": provider_match, + }, + "confidence": 0.88 if dashboard_id else 0.64, + "risk_level": "guarded", + "requires_confirmation": False, + } + + # Documentation + if any(k in lower for k in ["документац", "documentation", "generate docs", "сгенерируй док"]): + env_match = _extract_id(lower, [r"(?:в|for|env|окружени[ея])\s+([a-z0-9_-]+)"]) + provider_match = _extract_id(lower, [r"(?:provider|провайдер)\s+([a-z0-9_-]+)"]) + 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, + } + + return { + "domain": "unknown", + "operation": "clarify", + "entities": {}, + "confidence": 0.3, + "risk_level": "safe", + "requires_confirmation": False, + } +# [/DEF:_parse_command:Function] + + +# [DEF:_check_any_permission:Function] +# @PURPOSE: 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") +# [/DEF:_check_any_permission:Function] + + +# [DEF:_authorize_intent:Function] +# @PURPOSE: Validate user permissions for parsed intent before confirmation/dispatch. +# @PRE: intent.operation is present for known assistant command domains. +# @POST: Returns if authorized; raises HTTPException(403) when denied. +def _authorize_intent(intent: Dict[str, Any], current_user: User): + operation = intent.get("operation") + checks_map: Dict[str, List[Tuple[str, str]]] = { + "get_task_status": [("tasks", "READ")], + "create_branch": [("plugin:git", "EXECUTE")], + "commit_changes": [("plugin:git", "EXECUTE")], + "deploy_dashboard": [("plugin:git", "EXECUTE")], + "execute_migration": [("plugin:migration", "EXECUTE"), ("plugin:superset-migration", "EXECUTE")], + "run_backup": [("plugin:superset-backup", "EXECUTE"), ("plugin:backup", "EXECUTE")], + "run_llm_validation": [("plugin:llm_dashboard_validation", "EXECUTE")], + "run_llm_documentation": [("plugin:llm_documentation", "EXECUTE")], + } + if operation in checks_map: + _check_any_permission(current_user, checks_map[operation]) +# [/DEF:_authorize_intent:Function] + + +# [DEF:_dispatch_intent:Function] +# @PURPOSE: Execute parsed assistant intent via existing task/plugin/git services. +# @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. +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]]: + operation = intent.get("operation") + entities = intent.get("entities", {}) + + 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: + return "У вас пока нет задач в истории.", None, [] + task = recent[0] + return ( + f"Последняя задача: {task.id}, статус: {task.status}.", + task.id, + [AssistantAction(type="open_task", label="Open Task", target=task.id)], + ) + + task = task_manager.get_task(task_id) + if not task: + raise HTTPException(status_code=404, detail=f"Task {task_id} not found") + return ( + f"Статус задачи {task.id}: {task.status}.", + task.id, + [AssistantAction(type="open_task", label="Open Task", target=task.id)], + ) + + if operation == "create_branch": + _check_any_permission(current_user, [("plugin:git", "EXECUTE")]) + dashboard_id = entities.get("dashboard_id") + branch_name = entities.get("branch_name") + if not dashboard_id or not branch_name: + raise HTTPException(status_code=400, detail="Missing dashboard_id or branch_name") + git_service.create_branch(int(dashboard_id), branch_name, "main") + return f"Ветка `{branch_name}` создана для дашборда {dashboard_id}.", None, [] + + if operation == "commit_changes": + _check_any_permission(current_user, [("plugin:git", "EXECUTE")]) + dashboard_id = entities.get("dashboard_id") + commit_message = entities.get("message") + if not dashboard_id: + raise HTTPException(status_code=400, detail="Missing dashboard_id") + git_service.commit_changes(int(dashboard_id), commit_message, None) + return "Коммит выполнен успешно.", None, [] + + if operation == "deploy_dashboard": + _check_any_permission(current_user, [("plugin:git", "EXECUTE")]) + dashboard_id = entities.get("dashboard_id") + env_token = entities.get("environment") + env_id = _resolve_env_id(env_token, config_manager) + if not dashboard_id or not env_id: + raise HTTPException(status_code=400, detail="Missing dashboard_id or environment") + + task = await task_manager.create_task( + plugin_id="git-integration", + params={ + "operation": "deploy", + "dashboard_id": int(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"), + ], + ) + + if operation == "execute_migration": + _check_any_permission(current_user, [("plugin:migration", "EXECUTE"), ("plugin:superset-migration", "EXECUTE")]) + dashboard_id = entities.get("dashboard_id") + src = _resolve_env_id(entities.get("source_env"), config_manager) + tgt = _resolve_env_id(entities.get("target_env"), config_manager) + if not dashboard_id or not src or not tgt: + raise HTTPException(status_code=400, detail="Missing dashboard_id/source_env/target_env") + + task = await task_manager.create_task( + plugin_id="superset-migration", + params={ + "selected_ids": [int(dashboard_id)], + "source_env_id": src, + "target_env_id": tgt, + "replace_db_config": False, + }, + 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"), + ], + ) + + if operation == "run_backup": + _check_any_permission(current_user, [("plugin:superset-backup", "EXECUTE"), ("plugin:backup", "EXECUTE")]) + env_id = _resolve_env_id(entities.get("environment"), 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"): + params["dashboard_ids"] = [int(entities["dashboard_id"])] + + task = await task_manager.create_task( + plugin_id="superset-backup", + params=params, + 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"), + ], + ) + + if operation == "run_llm_validation": + _check_any_permission(current_user, [("plugin:llm_dashboard_validation", "EXECUTE")]) + dashboard_id = entities.get("dashboard_id") + env_id = _resolve_env_id(entities.get("environment"), config_manager) + provider_id = _resolve_provider_id(entities.get("provider"), db) + if not dashboard_id or not env_id or not provider_id: + raise HTTPException(status_code=400, detail="Missing dashboard_id/environment/provider") + + 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"), + ], + ) + + 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) + 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"), + ], + ) + + raise HTTPException(status_code=400, detail="Unsupported operation") +# [/DEF:_dispatch_intent:Function] + + +@router.post("/messages", response_model=AssistantMessageResponse) +# [DEF:send_message:Function] +# @PURPOSE: Parse assistant command, enforce safety gates, and dispatch executable intent. +# @PRE: Authenticated user is available and message text is non-empty. +# @POST: Response state is one of clarification/confirmation/started/success/denied/failed. +# @RETURN: AssistantMessageResponse with operation feedback and optional actions. +async def send_message( + request: AssistantMessageRequest, + current_user: User = Depends(get_current_user), + task_manager: TaskManager = Depends(get_task_manager), + config_manager: ConfigManager = Depends(get_config_manager), + db: Session = Depends(get_db), +): + with belief_scope("assistant.send_message"): + user_id = current_user.id + conversation_id = _resolve_or_create_conversation(user_id, request.conversation_id, db) + + _append_history(user_id, conversation_id, "user", request.message) + _persist_message(db, user_id, conversation_id, "user", request.message) + + intent = _parse_command(request.message, config_manager) + confidence = float(intent.get("confidence", 0.0)) + + if intent.get("domain") == "unknown" or confidence < 0.6: + text = "Команда неоднозначна. Уточните действие: git / migration / backup / llm / status." + _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} + _audit(user_id, audit_payload) + _persist_audit(db, user_id, audit_payload, conversation_id) + return AssistantMessageResponse( + conversation_id=conversation_id, + response_id=str(uuid.uuid4()), + state="needs_clarification", + text=text, + intent=intent, + actions=[AssistantAction(type="rephrase", label="Rephrase command")], + created_at=datetime.utcnow(), + ) + + try: + _authorize_intent(intent, current_user) + + if intent.get("requires_confirmation"): + 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.utcnow() + timedelta(minutes=5), + created_at=datetime.utcnow(), + ) + CONFIRMATIONS[confirmation_id] = confirm + _persist_confirmation(db, confirm) + text = "Операция рискованная. Подтвердите выполнение или отмените." + _append_history( + user_id, + conversation_id, + "assistant", + text, + state="needs_confirmation", + confirmation_id=confirmation_id, + ) + _persist_message( + db, + user_id, + conversation_id, + "assistant", + text, + state="needs_confirmation", + confirmation_id=confirmation_id, + metadata={"intent": intent}, + ) + audit_payload = { + "decision": "needs_confirmation", + "message": request.message, + "intent": intent, + "confirmation_id": confirmation_id, + } + _audit(user_id, audit_payload) + _persist_audit(db, user_id, audit_payload, conversation_id) + 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="Confirm", target=confirmation_id), + AssistantAction(type="cancel", label="Cancel", target=confirmation_id), + ], + created_at=datetime.utcnow(), + ) + + text, task_id, actions = await _dispatch_intent(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, "actions": [a.model_dump() for a in actions]}, + ) + audit_payload = {"decision": "executed", "message": request.message, "intent": intent, "task_id": task_id} + _audit(user_id, audit_payload) + _persist_audit(db, user_id, audit_payload, conversation_id) + return AssistantMessageResponse( + conversation_id=conversation_id, + response_id=str(uuid.uuid4()), + state=state, + text=text, + intent=intent, + task_id=task_id, + actions=actions, + created_at=datetime.utcnow(), + ) + except HTTPException as exc: + state = "denied" if exc.status_code == status.HTTP_403_FORBIDDEN else "failed" + text = str(exc.detail) + _append_history(user_id, conversation_id, "assistant", text, state=state) + _persist_message(db, user_id, conversation_id, "assistant", text, state=state, metadata={"intent": intent}) + audit_payload = {"decision": state, "message": request.message, "intent": intent, "error": text} + _audit(user_id, audit_payload) + _persist_audit(db, user_id, audit_payload, conversation_id) + return AssistantMessageResponse( + conversation_id=conversation_id, + response_id=str(uuid.uuid4()), + state=state, + text=text, + intent=intent, + actions=[], + created_at=datetime.utcnow(), + ) +# [/DEF:send_message:Function] + + +@router.post("/confirmations/{confirmation_id}/confirm", response_model=AssistantMessageResponse) +# [DEF:confirm_operation:Function] +# @PURPOSE: Execute previously requested risky operation after explicit user confirmation. +# @PRE: confirmation_id exists, belongs to current user, is pending, and not expired. +# @POST: Confirmation state becomes consumed and operation result is persisted in history. +# @RETURN: AssistantMessageResponse with task details when async execution starts. +async def confirm_operation( + confirmation_id: str, + current_user: User = Depends(get_current_user), + task_manager: TaskManager = Depends(get_task_manager), + config_manager: ConfigManager = Depends(get_config_manager), + db: Session = Depends(get_db), +): + with belief_scope("assistant.confirm"): + record = CONFIRMATIONS.get(confirmation_id) + if not record: + record = _load_confirmation_from_db(db, confirmation_id) + if record: + CONFIRMATIONS[confirmation_id] = record + else: + raise HTTPException(status_code=404, detail="Confirmation not found") + + if record.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Confirmation does not belong to current user") + + if record.state != "pending": + raise HTTPException(status_code=400, detail=f"Confirmation already {record.state}") + + if datetime.utcnow() > record.expires_at: + record.state = "expired" + _update_confirmation_state(db, confirmation_id, "expired") + 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) + record.state = "consumed" + _update_confirmation_state(db, confirmation_id, "consumed") + + _append_history(current_user.id, record.conversation_id, "assistant", text, state="started" if task_id else "success", task_id=task_id) + _persist_message( + db, + current_user.id, + record.conversation_id, + "assistant", + text, + state="started" if task_id else "success", + task_id=task_id, + metadata={"intent": intent, "confirmation_id": confirmation_id}, + ) + audit_payload = {"decision": "confirmed_execute", "confirmation_id": confirmation_id, "task_id": task_id, "intent": intent} + _audit(current_user.id, audit_payload) + _persist_audit(db, current_user.id, audit_payload, record.conversation_id) + + return AssistantMessageResponse( + conversation_id=record.conversation_id, + response_id=str(uuid.uuid4()), + state="started" if task_id else "success", + text=text, + intent=intent, + task_id=task_id, + actions=actions, + created_at=datetime.utcnow(), + ) +# [/DEF:confirm_operation:Function] + + +@router.post("/confirmations/{confirmation_id}/cancel", response_model=AssistantMessageResponse) +# [DEF:cancel_operation:Function] +# @PURPOSE: Cancel pending risky operation and mark confirmation token as cancelled. +# @PRE: confirmation_id exists, belongs to current user, and is still pending. +# @POST: Confirmation becomes cancelled and cannot be executed anymore. +# @RETURN: AssistantMessageResponse confirming cancellation. +async def cancel_operation( + confirmation_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + with belief_scope("assistant.cancel"): + record = CONFIRMATIONS.get(confirmation_id) + if not record: + record = _load_confirmation_from_db(db, confirmation_id) + if record: + CONFIRMATIONS[confirmation_id] = record + else: + raise HTTPException(status_code=404, detail="Confirmation not found") + + if record.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Confirmation does not belong to current user") + + if record.state != "pending": + raise HTTPException(status_code=400, detail=f"Confirmation already {record.state}") + + record.state = "cancelled" + _update_confirmation_state(db, confirmation_id, "cancelled") + text = "Операция отменена. Выполнение не запускалось." + _append_history(current_user.id, record.conversation_id, "assistant", text, state="success", confirmation_id=confirmation_id) + _persist_message( + db, + current_user.id, + record.conversation_id, + "assistant", + text, + state="success", + confirmation_id=confirmation_id, + metadata={"intent": record.intent}, + ) + audit_payload = {"decision": "cancelled", "confirmation_id": confirmation_id, "intent": record.intent} + _audit(current_user.id, audit_payload) + _persist_audit(db, current_user.id, audit_payload, record.conversation_id) + + return AssistantMessageResponse( + conversation_id=record.conversation_id, + response_id=str(uuid.uuid4()), + state="success", + text=text, + intent=record.intent, + confirmation_id=confirmation_id, + actions=[], + created_at=datetime.utcnow(), + ) +# [/DEF:cancel_operation:Function] + + +@router.get("/history") +# [DEF:get_history:Function] +# @PURPOSE: Retrieve paginated assistant conversation history for current user. +# @PRE: Authenticated user is available and page params are valid. +# @POST: Returns persistent messages and mirrored in-memory snapshot for diagnostics. +# @RETURN: Dict with items, paging metadata, and resolved conversation_id. +async def get_history( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + conversation_id: Optional[str] = Query(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + with belief_scope("assistant.history"): + user_id = current_user.id + conv_id = _resolve_or_create_conversation(user_id, conversation_id, db) + + query = ( + db.query(AssistantMessageRecord) + .filter( + AssistantMessageRecord.user_id == user_id, + AssistantMessageRecord.conversation_id == conv_id, + ) + .order_by(AssistantMessageRecord.created_at.asc()) + ) + total = query.count() + start = (page - 1) * page_size + rows = query.offset(start).limit(page_size).all() + + persistent_items = [ + { + "message_id": row.id, + "conversation_id": row.conversation_id, + "role": row.role, + "text": row.text, + "state": row.state, + "task_id": row.task_id, + "confirmation_id": row.confirmation_id, + "created_at": row.created_at.isoformat() if row.created_at else None, + "metadata": row.payload, + } + for row in rows + ] + + memory_items = CONVERSATIONS.get((user_id, conv_id), []) + return { + "items": persistent_items, + "memory_items": memory_items, + "total": total, + "page": page, + "page_size": page_size, + "has_next": start + page_size < total, + "conversation_id": conv_id, + } +# [/DEF:get_history:Function] + + +@router.get("/audit") +# [DEF:get_assistant_audit:Function] +# @PURPOSE: Return assistant audit decisions for current user from persistent and in-memory stores. +# @PRE: User has tasks:READ permission. +# @POST: Audit payload is returned in reverse chronological order from DB. +# @RETURN: Dict with persistent and memory audit slices. +async def get_assistant_audit( + limit: int = Query(50, ge=1, le=500), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), + _=Depends(has_permission("tasks", "READ")), +): + with belief_scope("assistant.audit"): + memory_rows = ASSISTANT_AUDIT.get(current_user.id, []) + db_rows = ( + db.query(AssistantAuditRecord) + .filter(AssistantAuditRecord.user_id == current_user.id) + .order_by(AssistantAuditRecord.created_at.desc()) + .limit(limit) + .all() + ) + persistent = [ + { + "id": row.id, + "user_id": row.user_id, + "conversation_id": row.conversation_id, + "decision": row.decision, + "task_id": row.task_id, + "message": row.message, + "payload": row.payload, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in db_rows + ] + return { + "items": persistent, + "memory_items": memory_rows[-limit:], + "total": len(persistent), + "memory_total": len(memory_rows), + } +# [/DEF:get_assistant_audit:Function] + +# [/DEF:backend.src.api.routes.assistant:Module] diff --git a/backend/src/app.py b/backend/src/app.py index cd445be..d3509a5 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -21,7 +21,7 @@ import asyncio from .dependencies import get_task_manager, get_scheduler_service from .core.utils.network import NetworkError from .core.logger import logger, belief_scope -from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports +from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant from .api import auth # [DEF:App:Global] @@ -119,11 +119,12 @@ app.include_router(environments.router, tags=["Environments"]) app.include_router(mappings.router, prefix="/api/mappings", tags=["Mappings"]) app.include_router(migration.router) app.include_router(git.router, prefix="/api/git", tags=["Git"]) -app.include_router(llm.router, prefix="/api/llm", tags=["LLM"]) -app.include_router(storage.router, prefix="/api/storage", tags=["Storage"]) -app.include_router(dashboards.router) -app.include_router(datasets.router) -app.include_router(reports.router) +app.include_router(llm.router, prefix="/api/llm", tags=["LLM"]) +app.include_router(storage.router, prefix="/api/storage", tags=["Storage"]) +app.include_router(dashboards.router) +app.include_router(datasets.router) +app.include_router(reports.router) +app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"]) # [DEF:api.include_routers:Action] diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 2fbccfd..ba0838d 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -1,11 +1,12 @@ # [DEF:backend.src.core.database:Module] # +# @TIER: STANDARD # @SEMANTICS: database, postgresql, sqlalchemy, session, persistence # @PURPOSE: Configures database connection and session management (PostgreSQL-first). # @LAYER: Core # @RELATION: DEPENDS_ON -> sqlalchemy -# @RELATION: USES -> backend.src.models.mapping -# @RELATION: USES -> backend.src.core.auth.config +# @RELATION: DEPENDS_ON -> backend.src.models.mapping +# @RELATION: DEPENDS_ON -> backend.src.core.auth.config # # @INVARIANT: A single engine instance is used for the entire application. @@ -18,6 +19,7 @@ from ..models import task as _task_models # noqa: F401 from ..models import auth as _auth_models # noqa: F401 from ..models import config as _config_models # noqa: F401 from ..models import llm as _llm_models # noqa: F401 +from ..models import assistant as _assistant_models # noqa: F401 from .logger import belief_scope from .auth.config import auth_config import os @@ -72,18 +74,21 @@ auth_engine = _build_engine(AUTH_DATABASE_URL) # [/DEF:auth_engine:Variable] # [DEF:SessionLocal:Class] +# @TIER: TRIVIAL # @PURPOSE: A session factory for the main mappings database. # @PRE: engine is initialized. SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # [/DEF:SessionLocal:Class] # [DEF:TasksSessionLocal:Class] +# @TIER: TRIVIAL # @PURPOSE: A session factory for the tasks execution database. # @PRE: tasks_engine is initialized. TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine) # [/DEF:TasksSessionLocal:Class] # [DEF:AuthSessionLocal:Class] +# @TIER: TRIVIAL # @PURPOSE: A session factory for the authentication database. # @PRE: auth_engine is initialized. AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine) diff --git a/backend/src/models/assistant.py b/backend/src/models/assistant.py new file mode 100644 index 0000000..90f74bc --- /dev/null +++ b/backend/src/models/assistant.py @@ -0,0 +1,74 @@ +# [DEF:backend.src.models.assistant:Module] +# @TIER: STANDARD +# @SEMANTICS: assistant, audit, confirmation, chat +# @PURPOSE: SQLAlchemy models for assistant audit trail and confirmation tokens. +# @LAYER: Domain +# @RELATION: DEPENDS_ON -> backend.src.models.mapping +# @INVARIANT: Assistant records preserve immutable ids and creation timestamps. + +from datetime import datetime + +from sqlalchemy import Column, String, DateTime, JSON, Text + +from .mapping import Base + + +# [DEF:AssistantAuditRecord:Class] +# @TIER: STANDARD +# @PURPOSE: Store audit decisions and outcomes produced by assistant command handling. +# @PRE: user_id must identify the actor for every record. +# @POST: Audit payload remains available for compliance and debugging. +class AssistantAuditRecord(Base): + __tablename__ = "assistant_audit" + + id = Column(String, primary_key=True) + user_id = Column(String, index=True, nullable=False) + conversation_id = Column(String, index=True, nullable=True) + decision = Column(String, nullable=True) + task_id = Column(String, nullable=True) + message = Column(Text, nullable=True) + payload = Column(JSON, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) +# [/DEF:AssistantAuditRecord:Class] + + +# [DEF:AssistantMessageRecord:Class] +# @TIER: STANDARD +# @PURPOSE: Persist chat history entries for assistant conversations. +# @PRE: user_id, conversation_id, role and text must be present. +# @POST: Message row can be queried in chronological order. +class AssistantMessageRecord(Base): + __tablename__ = "assistant_messages" + + id = Column(String, primary_key=True) + user_id = Column(String, index=True, nullable=False) + conversation_id = Column(String, index=True, nullable=False) + role = Column(String, nullable=False) # user | assistant + text = Column(Text, nullable=False) + state = Column(String, nullable=True) + task_id = Column(String, nullable=True) + confirmation_id = Column(String, nullable=True) + payload = Column(JSON, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) +# [/DEF:AssistantMessageRecord:Class] + + +# [DEF:AssistantConfirmationRecord:Class] +# @TIER: STANDARD +# @PURPOSE: Persist risky operation confirmation tokens with lifecycle state. +# @PRE: intent/dispatch and expiry timestamp must be provided. +# @POST: State transitions can be tracked and audited. +class AssistantConfirmationRecord(Base): + __tablename__ = "assistant_confirmations" + + id = Column(String, primary_key=True) + user_id = Column(String, index=True, nullable=False) + conversation_id = Column(String, index=True, nullable=False) + state = Column(String, index=True, nullable=False, default="pending") + intent = Column(JSON, nullable=False) + dispatch = Column(JSON, nullable=False) + expires_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + consumed_at = Column(DateTime, nullable=True) +# [/DEF:AssistantConfirmationRecord:Class] +# [/DEF:backend.src.models.assistant:Module] diff --git a/frontend/src/lib/api/assistant.js b/frontend/src/lib/api/assistant.js new file mode 100644 index 0000000..60d97e0 --- /dev/null +++ b/frontend/src/lib/api/assistant.js @@ -0,0 +1,50 @@ +// [DEF:frontend.src.lib.api.assistant:Module] +// @TIER: STANDARD +// @SEMANTICS: assistant, api, client, chat, confirmation +// @PURPOSE: API client wrapper for assistant chat, confirmation actions, and history retrieval. +// @LAYER: Infra-API +// @RELATION: DEPENDS_ON -> frontend.src.lib.api.api_module +// @INVARIANT: All assistant requests must use requestApi wrapper (no native fetch). + +import { requestApi } from '$lib/api.js'; + +// [DEF:sendAssistantMessage:Function] +// @PURPOSE: Send a user message to assistant orchestrator endpoint. +// @PRE: payload.message is a non-empty string. +// @POST: Returns assistant response object with deterministic state. +export function sendAssistantMessage(payload) { + return requestApi('/assistant/messages', 'POST', payload); +} +// [/DEF:sendAssistantMessage:Function] + +// [DEF:confirmAssistantOperation:Function] +// @PURPOSE: Confirm a pending risky assistant operation. +// @PRE: confirmationId references an existing pending token. +// @POST: Returns execution response (started/success/failed). +export function confirmAssistantOperation(confirmationId) { + return requestApi(`/assistant/confirmations/${confirmationId}/confirm`, 'POST'); +} +// [/DEF:confirmAssistantOperation:Function] + +// [DEF:cancelAssistantOperation:Function] +// @PURPOSE: Cancel a pending risky assistant operation. +// @PRE: confirmationId references an existing pending token. +// @POST: Operation is cancelled and cannot be executed by this token. +export function cancelAssistantOperation(confirmationId) { + return requestApi(`/assistant/confirmations/${confirmationId}/cancel`, 'POST'); +} +// [/DEF:cancelAssistantOperation:Function] + +// [DEF:getAssistantHistory:Function] +// @PURPOSE: Retrieve paginated assistant conversation history. +// @PRE: page/pageSize are positive integers. +// @POST: Returns a paginated payload with history items. +export function getAssistantHistory(page = 1, pageSize = 20, conversationId = null) { + const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); + if (conversationId) { + params.append('conversation_id', conversationId); + } + return requestApi(`/assistant/history?${params.toString()}`, 'GET'); +} +// [/DEF:getAssistantHistory:Function] +// [/DEF:frontend.src.lib.api.assistant:Module] diff --git a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte new file mode 100644 index 0000000..551ad0a --- /dev/null +++ b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte @@ -0,0 +1,342 @@ + + + +{#if isOpen} +
+ + +{/if} + diff --git a/frontend/src/lib/components/assistant/__tests__/assistant_chat.integration.test.js b/frontend/src/lib/components/assistant/__tests__/assistant_chat.integration.test.js new file mode 100644 index 0000000..7e786fe --- /dev/null +++ b/frontend/src/lib/components/assistant/__tests__/assistant_chat.integration.test.js @@ -0,0 +1,87 @@ +// [DEF:frontend.src.lib.components.assistant.__tests__.assistant_chat_integration:Module] +// @TIER: STANDARD +// @SEMANTICS: assistant, integration-test, ux-contract, i18n +// @PURPOSE: Contract-level integration checks for assistant chat panel implementation and localization wiring. +// @LAYER: UI Tests +// @RELATION: VERIFIES -> frontend/src/lib/components/assistant/AssistantChatPanel.svelte +// @INVARIANT: Critical assistant UX states and action hooks remain present in component source. + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const COMPONENT_PATH = path.resolve( + process.cwd(), + 'src/lib/components/assistant/AssistantChatPanel.svelte', +); +const EN_LOCALE_PATH = path.resolve( + process.cwd(), + 'src/lib/i18n/locales/en.json', +); +const RU_LOCALE_PATH = path.resolve( + process.cwd(), + 'src/lib/i18n/locales/ru.json', +); + +// [DEF:readJson:Function] +// @PURPOSE: Read and parse JSON fixture file from disk. +// @PRE: filePath points to existing UTF-8 JSON file. +// @POST: Returns parsed object representation. +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); +} +// [/DEF:readJson:Function] + +// [DEF:assistant_chat_contract_tests:Function] +// @PURPOSE: Validate assistant chat component contract and locale integration without DOM runtime dependency. +// @PRE: Component and locale files exist in expected paths. +// @POST: Contract checks guarantee assistant UI anchors and i18n wiring remain intact. +describe('AssistantChatPanel integration contract', () => { + it('contains semantic anchors and UX contract tags', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + + expect(source).toContain(''); + expect(source).toContain('@TIER: CRITICAL'); + expect(source).toContain('@UX_STATE: LoadingHistory'); + expect(source).toContain('@UX_STATE: Sending'); + expect(source).toContain('@UX_STATE: Error'); + expect(source).toContain('@UX_FEEDBACK: Started operation surfaces task_id'); + expect(source).toContain('@UX_RECOVERY: User can retry command'); + expect(source).toContain(''); + }); + + it('keeps confirmation/task-tracking action hooks in place', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + + expect(source).toContain("if (action.type === 'confirm' && message.confirmation_id)"); + expect(source).toContain("if (action.type === 'cancel' && message.confirmation_id)"); + expect(source).toContain("if (action.type === 'open_task' && action.target)"); + expect(source).toContain('openDrawerForTask(action.target)'); + expect(source).toContain("goto('/reports')"); + }); + + it('uses i18n bindings for assistant UI labels', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + + expect(source).toContain('$t.assistant?.title'); + expect(source).toContain('$t.assistant?.input_placeholder'); + expect(source).toContain('$t.assistant?.send'); + expect(source).toContain('$t.assistant?.states?.[message.state]'); + expect(source).toContain('$t.assistant?.open_task_drawer'); + }); + + it('provides assistant locale keys in both en and ru dictionaries', () => { + const en = readJson(EN_LOCALE_PATH); + const ru = readJson(RU_LOCALE_PATH); + + expect(en.assistant.title).toBeTruthy(); + expect(en.assistant.send).toBeTruthy(); + expect(en.assistant.states.needs_confirmation).toBeTruthy(); + + expect(ru.assistant.title).toBeTruthy(); + expect(ru.assistant.send).toBeTruthy(); + expect(ru.assistant.states.needs_confirmation).toBeTruthy(); + }); +}); +// [/DEF:assistant_chat_contract_tests:Function] +// [/DEF:frontend.src.lib.components.assistant.__tests__.assistant_chat_integration:Module] diff --git a/frontend/src/lib/components/assistant/__tests__/assistant_confirmation.integration.test.js b/frontend/src/lib/components/assistant/__tests__/assistant_confirmation.integration.test.js new file mode 100644 index 0000000..f6a14e0 --- /dev/null +++ b/frontend/src/lib/components/assistant/__tests__/assistant_confirmation.integration.test.js @@ -0,0 +1,50 @@ +// [DEF:frontend.src.lib.components.assistant.__tests__.assistant_confirmation_integration:Module] +// @TIER: STANDARD +// @SEMANTICS: assistant, confirmation, integration-test, ux +// @PURPOSE: Validate confirm/cancel UX contract bindings in assistant chat panel source. +// @LAYER: UI Tests +// @RELATION: VERIFIES -> frontend/src/lib/components/assistant/AssistantChatPanel.svelte +// @INVARIANT: Confirm/cancel action handling must remain explicit and confirmation-id bound. + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const COMPONENT_PATH = path.resolve( + process.cwd(), + 'src/lib/components/assistant/AssistantChatPanel.svelte', +); + +// [DEF:assistant_confirmation_contract_tests:Function] +// @PURPOSE: Assert that confirmation UX flow and API bindings are preserved in chat panel. +// @PRE: Assistant panel source file exists and is readable. +// @POST: Test guarantees explicit confirm/cancel guards and failed-action recovery path. +describe('AssistantChatPanel confirmation integration contract', () => { + it('contains confirmation action guards with confirmation_id checks', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + + expect(source).toContain("if (action.type === 'confirm' && message.confirmation_id)"); + expect(source).toContain("if (action.type === 'cancel' && message.confirmation_id)"); + expect(source).toContain('confirmAssistantOperation(message.confirmation_id)'); + expect(source).toContain('cancelAssistantOperation(message.confirmation_id)'); + }); + + it('renders action buttons from assistant response payload', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + + expect(source).toContain('{#if message.actions?.length}'); + expect(source).toContain('{#each message.actions as action}'); + expect(source).toContain('{action.label}'); + expect(source).toContain('on:click={() => handleAction(action, message)}'); + }); + + it('keeps failed-action recovery response path', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + + expect(source).toContain("response_id: `action-error-${Date.now()}`"); + expect(source).toContain("state: 'failed'"); + expect(source).toContain("text: err.message || 'Action failed'"); + }); +}); +// [/DEF:assistant_confirmation_contract_tests:Function] +// [/DEF:frontend.src.lib.components.assistant.__tests__.assistant_confirmation_integration:Module] diff --git a/frontend/src/lib/components/layout/TopNavbar.svelte b/frontend/src/lib/components/layout/TopNavbar.svelte index 18a5527..876c23b 100644 --- a/frontend/src/lib/components/layout/TopNavbar.svelte +++ b/frontend/src/lib/components/layout/TopNavbar.svelte @@ -12,6 +12,8 @@ * @UX_STATE: SearchFocused -> Search input expands * @UX_FEEDBACK: Activity badge shows count of running tasks * @UX_RECOVERY: Click outside closes dropdowns + * @UX_TEST: SearchFocused -> {focus: search input, expected: focused style class applied} + * @UX_TEST: ActivityClick -> {click: activity button, expected: task drawer opens} */ import { createEventDispatcher } from "svelte"; @@ -25,6 +27,7 @@ import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.js"; import { t } from "$lib/i18n"; import { auth } from "$lib/auth/store.js"; + import { toggleAssistantChat } from "$lib/stores/assistantChat.js"; import Icon from "$lib/ui/Icon.svelte"; const dispatch = createEventDispatcher(); @@ -64,6 +67,10 @@ dispatch("activityClick"); } + function handleAssistantClick() { + toggleAssistantChat(); + } + function handleSearchFocus() { isSearchFocused = true; } @@ -129,6 +136,16 @@