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 @@
+ + +
assistantChatStore +// @INVARIANT: Each test starts from default closed state. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { get } from 'svelte/store'; +import { + assistantChatStore, + toggleAssistantChat, + openAssistantChat, + closeAssistantChat, + setAssistantConversationId, +} from '../assistantChat.js'; + +// [DEF:assistantChatStore_tests:Function] +// @TIER: STANDARD +// @PURPOSE: Group store unit scenarios for assistant panel behavior. +// @PRE: Store can be reset to baseline state in beforeEach hook. +// @POST: Open/close/toggle/conversation transitions are validated. +describe('assistantChatStore', () => { + beforeEach(() => { + assistantChatStore.set({ + isOpen: false, + conversationId: null, + }); + }); + + it('should open assistant panel', () => { + openAssistantChat(); + const state = get(assistantChatStore); + expect(state.isOpen).toBe(true); + }); + + it('should close assistant panel', () => { + openAssistantChat(); + closeAssistantChat(); + const state = get(assistantChatStore); + expect(state.isOpen).toBe(false); + }); + + it('should toggle assistant panel state', () => { + toggleAssistantChat(); + expect(get(assistantChatStore).isOpen).toBe(true); + toggleAssistantChat(); + expect(get(assistantChatStore).isOpen).toBe(false); + }); + + it('should set conversation id', () => { + setAssistantConversationId('conv-123'); + const state = get(assistantChatStore); + expect(state.conversationId).toBe('conv-123'); + }); +}); +// [/DEF:assistantChatStore_tests:Function] +// [/DEF:frontend.src.lib.stores.__tests__.assistantChat:Module] diff --git a/frontend/src/lib/stores/assistantChat.js b/frontend/src/lib/stores/assistantChat.js new file mode 100644 index 0000000..86378e5 --- /dev/null +++ b/frontend/src/lib/stores/assistantChat.js @@ -0,0 +1,71 @@ +// [DEF:assistantChat:Store] +// @TIER: STANDARD +// @SEMANTICS: assistant, store, ui-state, conversation +// @PURPOSE: Control assistant chat panel visibility and active conversation binding. +// @LAYER: UI +// @RELATION: BINDS_TO -> AssistantChatPanel +// @INVARIANT: conversationId persists while panel toggles unless explicitly reset. +// +// @UX_STATE: Closed -> Panel hidden. +// @UX_STATE: Open -> Panel visible and interactive. + +import { writable } from 'svelte/store'; + +const initialState = { + isOpen: false, + conversationId: null, +}; + +export const assistantChatStore = writable(initialState); + +// [DEF:toggleAssistantChat:Function] +// @PURPOSE: Toggle assistant panel visibility. +// @PRE: Store is initialized. +// @POST: isOpen value inverted. +export function toggleAssistantChat() { + assistantChatStore.update((state) => { + const next = { ...state, isOpen: !state.isOpen }; + console.log(`[assistantChat][${next.isOpen ? 'Open' : 'Closed'}] toggleAssistantChat`); + return next; + }); +} +// [/DEF:toggleAssistantChat:Function] + +// [DEF:openAssistantChat:Function] +// @PURPOSE: Open assistant panel. +// @PRE: Store is initialized. +// @POST: isOpen = true. +export function openAssistantChat() { + assistantChatStore.update((state) => { + const next = { ...state, isOpen: true }; + console.log('[assistantChat][Open] openAssistantChat'); + return next; + }); +} +// [/DEF:openAssistantChat:Function] + +// [DEF:closeAssistantChat:Function] +// @PURPOSE: Close assistant panel. +// @PRE: Store is initialized. +// @POST: isOpen = false. +export function closeAssistantChat() { + assistantChatStore.update((state) => { + const next = { ...state, isOpen: false }; + console.log('[assistantChat][Closed] closeAssistantChat'); + return next; + }); +} +// [/DEF:closeAssistantChat:Function] + +// [DEF:setAssistantConversationId:Function] +// @PURPOSE: Bind current conversation id in UI state. +// @PRE: conversationId is string-like identifier. +// @POST: store.conversationId updated. +export function setAssistantConversationId(conversationId) { + assistantChatStore.update((state) => { + console.log('[assistantChat][ConversationBound] setAssistantConversationId'); + return { ...state, conversationId }; + }); +} +// [/DEF:setAssistantConversationId:Function] +// [/DEF:assistantChat:Store] diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index dc11f39..68d5673 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -32,6 +32,7 @@ import Sidebar from '$lib/components/layout/Sidebar.svelte'; import TopNavbar from '$lib/components/layout/TopNavbar.svelte'; import TaskDrawer from '$lib/components/layout/TaskDrawer.svelte'; + import AssistantChatPanel from '$lib/components/assistant/AssistantChatPanel.svelte'; import { page } from '$app/stores'; import { sidebarStore } from '$lib/stores/sidebar.js'; @@ -71,6 +72,7 @@ + {/if} diff --git a/specs/021-llm-project-assistant/checklists/requirements.md b/specs/021-llm-project-assistant/checklists/requirements.md new file mode 100644 index 0000000..096f99c --- /dev/null +++ b/specs/021-llm-project-assistant/checklists/requirements.md @@ -0,0 +1,46 @@ +# Specification Quality Checklist: LLM Chat Assistant for Project Operations + +**Purpose**: Validate specification completeness, UX alignment, and safety before planning +**Created**: 2026-02-23 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (framework internals, code-level design) +- [x] Focused on user value and operational outcomes +- [x] All mandatory sections completed +- [x] User stories are independently testable + +## UX Consistency + +- [x] Functional requirements support happy path in `ux_reference.md` +- [x] Error experience in UX is reflected in requirements (clarification, deny, timeout, failure) +- [x] Response states are explicitly defined for frontend rendering + +## Security & Safety + +- [x] RBAC enforcement is mandatory for every execution path +- [x] Risky operations require explicit confirmation +- [x] Confirmation expiration/cancel behavior is defined +- [x] No hidden execution path for unconfirmed dangerous actions + +## Requirement Completeness + +- [x] No `[NEEDS CLARIFICATION]` markers remain +- [x] Requirements are testable and unambiguous +- [x] Edge cases include ambiguity, permission denial, invalid targets, duplicate actions +- [x] Success criteria are measurable and technology-agnostic +- [x] Assumptions and dependencies are identified + +## Feature Readiness + +- [x] Scope includes all requested command domains (Git, migration/backup, analysis/docs, status) +- [x] Long-running operation feedback with `task_id` is explicitly required +- [x] Tracking through existing Task Drawer/reports is explicitly required +- [x] Specification is ready for `/speckit.plan` and `/speckit.tasks` + +## Notes + +- Validation iteration: 1 +- Result: PASS +- No blocking issues found. diff --git a/specs/021-llm-project-assistant/contracts/assistant-api.openapi.yaml b/specs/021-llm-project-assistant/contracts/assistant-api.openapi.yaml new file mode 100644 index 0000000..6701dd0 --- /dev/null +++ b/specs/021-llm-project-assistant/contracts/assistant-api.openapi.yaml @@ -0,0 +1,270 @@ +openapi: 3.0.3 +info: + title: Assistant Chat API + version: 1.0.0 + description: API contract for LLM chat assistant command orchestration. + +servers: + - url: /api + +paths: + /assistant/messages: + post: + summary: Send user message to assistant + description: Parses command, applies safety checks, and returns assistant response state. + operationId: sendAssistantMessage + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AssistantMessageRequest' + responses: + '200': + description: Assistant response + content: + application/json: + schema: + $ref: '#/components/schemas/AssistantMessageResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + '403': + description: Forbidden + + /assistant/confirmations/{confirmation_id}/confirm: + post: + summary: Confirm dangerous operation + description: Confirms one pending risky command and executes it once. + operationId: confirmAssistantOperation + parameters: + - in: path + name: confirmation_id + required: true + schema: + type: string + responses: + '200': + description: Confirmation handled + content: + application/json: + schema: + $ref: '#/components/schemas/AssistantMessageResponse' + '400': + description: Invalid or expired confirmation + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: Confirmation not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /assistant/confirmations/{confirmation_id}/cancel: + post: + summary: Cancel dangerous operation + description: Cancels pending confirmation and prevents execution. + operationId: cancelAssistantOperation + parameters: + - in: path + name: confirmation_id + required: true + schema: + type: string + responses: + '200': + description: Confirmation cancelled + content: + application/json: + schema: + $ref: '#/components/schemas/AssistantMessageResponse' + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: Confirmation not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /assistant/history: + get: + summary: Get assistant conversation history + description: Returns paginated history for current user. + operationId: getAssistantHistory + parameters: + - in: query + name: page + schema: + type: integer + minimum: 1 + default: 1 + - in: query + name: page_size + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: Conversation history page + content: + application/json: + schema: + $ref: '#/components/schemas/AssistantHistoryPage' + '401': + description: Unauthorized + +components: + schemas: + AssistantResponseState: + type: string + enum: + - started + - success + - failed + - denied + - needs_confirmation + - needs_clarification + + RiskLevel: + type: string + enum: + - safe + - guarded + - dangerous + + CommandIntent: + type: object + properties: + domain: + type: string + operation: + type: string + entities: + type: object + additionalProperties: true + confidence: + type: number + minimum: 0 + maximum: 1 + risk_level: + $ref: '#/components/schemas/RiskLevel' + required: [domain, operation, confidence, risk_level] + + AssistantMessageRequest: + type: object + properties: + conversation_id: + type: string + nullable: true + message: + type: string + minLength: 1 + maxLength: 4000 + required: [message] + + AssistantAction: + type: object + properties: + type: + type: string + enum: [confirm, cancel, open_task, open_reports, rephrase] + label: + type: string + target: + type: string + nullable: true + required: [type, label] + + AssistantMessageResponse: + type: object + properties: + conversation_id: + type: string + response_id: + type: string + state: + $ref: '#/components/schemas/AssistantResponseState' + text: + type: string + intent: + $ref: '#/components/schemas/CommandIntent' + confirmation_id: + type: string + nullable: true + task_id: + type: string + nullable: true + actions: + type: array + items: + $ref: '#/components/schemas/AssistantAction' + created_at: + type: string + format: date-time + required: [conversation_id, response_id, state, text, created_at] + + AssistantMessage: + type: object + properties: + message_id: + type: string + conversation_id: + type: string + role: + type: string + enum: [user, assistant] + text: + type: string + state: + $ref: '#/components/schemas/AssistantResponseState' + task_id: + type: string + nullable: true + created_at: + type: string + format: date-time + required: [message_id, conversation_id, role, text, created_at] + + AssistantHistoryPage: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/AssistantMessage' + total: + type: integer + minimum: 0 + page: + type: integer + minimum: 1 + page_size: + type: integer + minimum: 1 + has_next: + type: boolean + required: [items, total, page, page_size, has_next] + + ErrorResponse: + type: object + properties: + detail: + type: string + required: [detail] diff --git a/specs/021-llm-project-assistant/contracts/modules.md b/specs/021-llm-project-assistant/contracts/modules.md new file mode 100644 index 0000000..2d2a652 --- /dev/null +++ b/specs/021-llm-project-assistant/contracts/modules.md @@ -0,0 +1,156 @@ +# Module Contracts: LLM Chat Assistant for Project Operations + +## Backend Assistant Orchestrator Module + +# [DEF:AssistantOrchestratorModule:Module] +# @TIER: CRITICAL +# @SEMANTICS: [assistant, intent, dispatch, safety] +# @PURPOSE: Parse chat commands, decide execution path, and orchestrate safe dispatch to existing operations. +# @LAYER: Domain +# @RELATION: DEPENDS_ON -> [DEF:AssistantIntentParserModule] +# @RELATION: DEPENDS_ON -> [DEF:AssistantSecurityGuardModule] +# @RELATION: DEPENDS_ON -> [DEF:AssistantExecutionAdapterModule] +# @RELATION: DEPENDS_ON -> [DEF:AssistantAuditLogModule] +# @INVARIANT: No operation is dispatched before permission and risk checks complete. +# @PRE: Authenticated user context and non-empty user message are provided. +# @POST: Returns one of deterministic states: needs_clarification, denied, needs_confirmation, started, success, failed. +# @POST: Long-running operation responses include task_id when task is created. +# [/DEF:AssistantOrchestratorModule] + +--- + +## Backend Assistant Intent Parser Module + +# [DEF:AssistantIntentParserModule:Module] +# @TIER: CRITICAL +# @SEMANTICS: [nlp, intent, entities, confidence] +# @PURPOSE: Convert RU/EN natural-language operation requests into normalized intent objects. +# @LAYER: Domain +# @RELATION: CALLED_BY -> [DEF:AssistantOrchestratorModule] +# @INVARIANT: Parsed intent always includes confidence score and risk_level. +# @PRE: Input text is available and sanitized. +# @POST: Returns normalized intent or unknown intent with low confidence. +# [/DEF:AssistantIntentParserModule] + +--- + +## Backend Assistant Security Guard Module + +# [DEF:AssistantSecurityGuardModule:Module] +# @TIER: CRITICAL +# @SEMANTICS: [rbac, confirmation, risk] +# @PURPOSE: Apply RBAC and dangerous-operation confirmation gates before execution. +# @LAYER: Security +# @RELATION: CALLED_BY -> [DEF:AssistantOrchestratorModule] +# @RELATION: DEPENDS_ON -> [DEF:MultiUserAuthModule] +# @INVARIANT: Dangerous operations never execute without explicit confirmation token. +# @PRE: Normalized intent and authenticated user context are available. +# @POST: Returns one of {denied, needs_confirmation, allowed}. +# [/DEF:AssistantSecurityGuardModule] + +--- + +## Backend Assistant Execution Adapter Module + +# [DEF:AssistantExecutionAdapterModule:Module] +# @TIER: CRITICAL +# @SEMANTICS: [adapter, task_manager, integrations] +# @PURPOSE: Map validated execution requests to existing Git/migration/backup/LLM/task status APIs. +# @LAYER: Integration +# @RELATION: CALLED_BY -> [DEF:AssistantOrchestratorModule] +# @RELATION: DEPENDS_ON -> [DEF:TasksRouter:Module] +# @RELATION: DEPENDS_ON -> [DEF:backend.src.api.routes.git:Module] +# @RELATION: DEPENDS_ON -> [DEF:backend.src.api.routes.migration:Module] +# @RELATION: DEPENDS_ON -> [DEF:backend/src/api/routes/llm.py:Module] +# @INVARIANT: Adapter reuses existing execution paths and does not duplicate domain logic. +# @PRE: ExecutionRequest is validated and authorized. +# @POST: Returns operation result or started task_id for async flows. +# [/DEF:AssistantExecutionAdapterModule] + +--- + +## Backend Assistant Audit Log Module + +# [DEF:AssistantAuditLogModule:Module] +# @TIER: STANDARD +# @SEMANTICS: [audit, traceability, observability] +# @PURPOSE: Persist assistant command processing decisions and outcomes. +# @LAYER: Infra +# @RELATION: CALLED_BY -> [DEF:AssistantOrchestratorModule] +# @INVARIANT: Every processed command emits one audit record. +# @PRE: Command processing context is available. +# @POST: Structured audit entry persisted with decision/outcome/task_id (if present). +# [/DEF:AssistantAuditLogModule] + +--- + +## Backend Assistant API Module + +# [DEF:AssistantApiContract:Module] +# @TIER: CRITICAL +# @SEMANTICS: [api, assistant, chat, confirm] +# @PURPOSE: Provide chat message, confirmation, and history endpoints for assistant workflows. +# @LAYER: Interface +# @RELATION: DEPENDS_ON -> [DEF:AssistantOrchestratorModule] +# @INVARIANT: API responses are deterministic and machine-readable for frontend state handling. +# @PRE: User authenticated. +# @POST: Message endpoint returns assistant response state + metadata. +# @POST: Confirm endpoint executes pending dangerous command exactly once. +# @POST: Cancel endpoint prevents execution of pending dangerous command. +# [/DEF:AssistantApiContract] + +--- + +## Frontend Assistant Chat Panel Contract + + +/** + * @TIER: CRITICAL + * @SEMANTICS: [ui, chat, commands, feedback] + * @PURPOSE: Render in-app assistant conversation and operational command interactions. + * @LAYER: UI + * @RELATION: DEPENDS_ON -> [DEF:AssistantApiClient] + * @RELATION: USES -> [DEF:TaskDrawerStore] + * @INVARIANT: Every assistant response is rendered with explicit state badge. + * @PRE: User is authenticated and assistant panel is accessible. + * @POST: User can send command, receive response, and confirm/cancel risky operations. + * @UX_STATE: Idle -> Prompt and examples are visible. + * @UX_STATE: Parsing -> Temporary loading message shown. + * @UX_STATE: NeedsConfirmation -> Confirmation card with Confirm/Cancel actions displayed. + * @UX_STATE: Started -> task_id chip and tracking hint displayed. + * @UX_STATE: Error -> Error card with retry/rephrase guidance displayed. + * @UX_RECOVERY: User can rephrase ambiguous command or retry after error. + */ + + +--- + +## Frontend Assistant API Client Contract + +# [DEF:AssistantApiClient:Module] +# @TIER: STANDARD +# @SEMANTICS: [frontend, api_client, assistant] +# @PURPOSE: Wrap assistant API requests through existing frontend API helpers. +# @LAYER: Infra +# @RELATION: CALLS -> [DEF:AssistantApiContract] +# @INVARIANT: No direct native fetch bypassing project API wrapper conventions. +# @PRE: Valid auth context/token exists. +# @POST: Returns typed assistant response payload or structured error object. +# [/DEF:AssistantApiClient] + +--- + +## Contract Usage Simulation (Key Scenario) + +Scenario traced: User requests production deploy in chat. + +1. `AssistantChatPanel` sends message to `AssistantApiClient`. +2. `AssistantApiContract` calls `AssistantOrchestratorModule`. +3. `AssistantIntentParserModule` extracts `domain=git`, `operation=deploy`, `target_env=production`, `risk_level=dangerous`. +4. `AssistantSecurityGuardModule` returns `needs_confirmation` and issues token. +5. UI shows confirmation card. +6. User confirms. +7. Confirm endpoint calls orchestrator with token. +8. `AssistantExecutionAdapterModule` dispatches existing deploy flow and returns `task_id`. +9. `AssistantAuditLogModule` records both confirmation request and final dispatch result. +10. UI shows `started` state with tracking hint (Task Drawer/reports). diff --git a/specs/021-llm-project-assistant/data-model.md b/specs/021-llm-project-assistant/data-model.md new file mode 100644 index 0000000..d00c446 --- /dev/null +++ b/specs/021-llm-project-assistant/data-model.md @@ -0,0 +1,156 @@ +# Data Model: LLM Chat Assistant for Project Operations + +**Feature**: [`021-llm-project-assistant`](specs/021-llm-project-assistant) +**Spec**: [`spec.md`](specs/021-llm-project-assistant/spec.md) +**Research**: [`research.md`](specs/021-llm-project-assistant/research.md) + +## 1. Entity: AssistantMessage + +Represents one chat message in assistant conversation. + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| message_id | string | Yes | Unique message identifier. | +| conversation_id | string | Yes | Logical chat session/thread identifier. | +| role | enum | Yes | `user`, `assistant`, `system`. | +| text | string | Yes | Message content. | +| created_at | datetime | Yes | Message timestamp. | +| metadata | object | No | Additional structured metadata (state badge, task link, etc.). | + +### Validation Rules + +- `text` must be non-empty. +- `role` must be one of allowed values. +- `conversation_id` must be bound to authenticated user scope. + +--- + +## 2. Entity: CommandIntent + +Represents parsed intent from user message. + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| intent_id | string | Yes | Parsed intent identifier. | +| domain | enum | Yes | `git`, `migration`, `backup`, `llm`, `status`, `report`, `unknown`. | +| operation | string | Yes | Normalized operation key (e.g., `create_branch`, `execute_migration`). | +| entities | object | No | Parsed parameters (`dashboard_id`, `env`, `task_id`, etc.). | +| confidence | number | Yes | Confidence score `0.0..1.0`. | +| risk_level | enum | Yes | `safe`, `guarded`, `dangerous`. | +| requires_confirmation | boolean | Yes | Indicates confirmation gate requirement. | + +### Validation Rules + +- `confidence` must be in `[0.0, 1.0]`. +- `requires_confirmation=true` when `risk_level=dangerous`. +- Missing mandatory entities triggers `needs_clarification` decision. + +--- + +## 3. Entity: ExecutionRequest + +Validated request ready for dispatch to existing backend action. + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| execution_id | string | Yes | Execution request identifier. | +| actor_user_id | string | Yes | Authenticated user initiating request. | +| intent_id | string | Yes | Source `CommandIntent` reference. | +| dispatch_target | string | Yes | Internal route/service/plugin target identifier. | +| payload | object | Yes | Validated action payload. | +| status | enum | Yes | `pending`, `dispatched`, `failed`, `denied`, `cancelled`. | +| task_id | string | No | Linked async task id if created. | + +### Validation Rules + +- Dispatch target must map to known execution backend. +- Permission checks must pass before `dispatched`. + +--- + +## 4. Entity: ConfirmationToken + +One-time confirmation artifact for dangerous operations. + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| confirmation_id | string | Yes | Unique confirmation token id. | +| actor_user_id | string | Yes | User who must confirm/cancel. | +| intent_snapshot | object | Yes | Immutable normalized intent snapshot. | +| expires_at | datetime | Yes | Token expiry timestamp. | +| state | enum | Yes | `pending`, `confirmed`, `cancelled`, `expired`, `consumed`. | +| created_at | datetime | Yes | Creation time. | +| consumed_at | datetime | No | Time of finalization. | + +### Validation Rules + +- Only token owner can confirm/cancel. +- `confirmed` token transitions to `consumed` after one dispatch. +- Expired token cannot transition to `confirmed`. + +--- + +## 5. Entity: AssistantExecutionLog + +Structured audit record of assistant decision and outcome. + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| log_id | string | Yes | Unique audit id. | +| user_id | string | Yes | Actor user id. | +| conversation_id | string | Yes | Conversation reference. | +| raw_message | string | Yes | Original user message. | +| normalized_intent | object | No | Parsed intent snapshot. | +| decision | enum | Yes | `executed`, `needs_confirmation`, `needs_clarification`, `denied`, `failed`. | +| outcome | enum | Yes | `success`, `error`, `started`, `cancelled`, `n/a`. | +| task_id | string | No | Linked async task id. | +| created_at | datetime | Yes | Audit timestamp. | + +### Validation Rules + +- Audit entry required for every command-processing attempt. +- `task_id` required when `outcome=started` for async operations. + +--- + +## 6. Relationships + +- `AssistantMessage` (user role) -> may produce one `CommandIntent`. +- `CommandIntent` -> may produce one `ExecutionRequest` or `ConfirmationToken`. +- `ConfirmationToken` (confirmed) -> produces one `ExecutionRequest`. +- `ExecutionRequest` -> may link to one async `task_id` in existing task manager. +- Every processed command -> one `AssistantExecutionLog`. + +--- + +## 7. State Flows + +### Command Decision Flow + +`received` -> `parsed` -> (`needs_clarification` | `denied` | `needs_confirmation` | `dispatch`) + +### Confirmation Flow + +`pending` -> (`confirmed` -> `consumed`) | `cancelled` | `expired` + +### Execution Flow + +`pending` -> (`dispatched` -> `started/success`) | `failed` | `denied` | `cancelled` + +--- + +## 8. Scale Assumptions + +- Conversation history is moderate per user and can be paginated. +- Command audit retention aligns with existing operational retention policies. +- Assistant parse step must remain lightweight and avoid blocking task execution system. diff --git a/specs/021-llm-project-assistant/plan.md b/specs/021-llm-project-assistant/plan.md new file mode 100644 index 0000000..af91180 --- /dev/null +++ b/specs/021-llm-project-assistant/plan.md @@ -0,0 +1,107 @@ +# Implementation Plan: LLM Chat Assistant for Project Operations + +**Branch**: `021-llm-project-assistant` | **Date**: 2026-02-23 | **Spec**: [`/home/busya/dev/ss-tools/specs/021-llm-project-assistant/spec.md`](specs/021-llm-project-assistant/spec.md) +**Input**: Feature specification from [`/specs/021-llm-project-assistant/spec.md`](specs/021-llm-project-assistant/spec.md) + +## Summary + +Implement an embedded LLM chat assistant that accepts natural-language commands and orchestrates existing `ss-tools` operations across Git, migration/backup, LLM analysis/documentation, and task status flows. The design reuses current backend routes/plugins and task manager, adds intent parsing + safe dispatch layer, enforces existing RBAC checks, and introduces explicit confirmation workflow for risky operations (especially production deploy). + +## Technical Context + +**Language/Version**: Python 3.9+ (backend), Node.js 18+ (frontend) +**Primary Dependencies**: FastAPI, existing TaskManager, existing plugin routes/services, SvelteKit chat UI components, configured LLM providers +**Storage**: Existing DB for auth/tasks/config + new assistant command audit persistence (same backend DB stack) +**Testing**: pytest (backend), Vitest (frontend), API contract tests for assistant endpoints +**Target Platform**: Linux backend + browser SPA frontend +**Project Type**: Web application (backend + frontend) +**Performance Goals**: Parse/dispatch response under 2s p95 (excluding async task runtime); confirmation round-trip under 1s p95 +**Constraints**: Must not bypass existing permissions; dangerous ops require explicit confirmation; must reuse existing task progress surfaces (Task Drawer/reports) +**Scale/Scope**: Multi-command operational assistant used by authenticated operators; daily command volume expected in hundreds to low thousands + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Gate | Status | Notes | +|---|---|---| +| Semantic Protocol Compliance ([`constitution.md`](.ai/standards/constitution.md)) | PASS | Contracts will define assistant modules with DEF tags and PRE/POST semantics. | +| Plugin/Service Boundaries | PASS | Assistant dispatch layer will call existing routes/services/plugins only; no direct bypass logic. | +| Security & RBAC | PASS | Command dispatch requires existing `has_permission` checks and user context propagation. | +| Async Task Architecture | PASS | Long-running operations stay task-based; assistant returns `task_id` immediately. | +| UX Consistency | PASS | Chat states map to explicit UX states (`needs_confirmation`, `started`, `failed`, etc.). | +| Auditability | PASS | Assistant command audit log added as mandatory trail for execution decisions. | + +## Project Structure + +### Documentation (this feature) + +```text +specs/021-llm-project-assistant/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── ux_reference.md +├── contracts/ +│ ├── modules.md +│ └── assistant-api.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +backend/ +├── src/ +│ ├── api/ +│ │ └── routes/ +│ ├── services/ +│ ├── models/ +│ └── core/ +└── tests/ + +frontend/ +├── src/ +│ ├── lib/ +│ │ ├── components/ +│ │ ├── stores/ +│ │ └── api/ +│ └── routes/ +└── tests/ +``` + +**Structure Decision**: Keep current backend/frontend split and implement assistant as a new bounded feature slice (assistant API route + intent/dispatch service + chat UI components) that integrates into existing auth/task/report ecosystems. + +## Phase 0: Research Focus + +1. Intent parsing strategy for RU/EN operational commands with deterministic fallback behavior. +2. Risk classification strategy for commands requiring explicit confirmation (e.g., prod deploy, prod migration). +3. Integration mapping from intents to existing operational endpoints/plugins. +4. Confirmation token lifecycle and idempotency guarantees. +5. Audit logging schema and retention alignment with task/audit expectations. + +## Phase 1: Design & Contracts Plan + +1. Define canonical assistant interaction model (`message -> intent -> decision -> dispatch/result`). +2. Produce [`data-model.md`](specs/021-llm-project-assistant/data-model.md) for message, intent, confirmation, and audit entities. +3. Produce module contracts in [`contracts/modules.md`](specs/021-llm-project-assistant/contracts/modules.md) with PRE/POST + UX states. +4. Produce API contract in [`contracts/assistant-api.openapi.yaml`](specs/021-llm-project-assistant/contracts/assistant-api.openapi.yaml). +5. Produce [`quickstart.md`](specs/021-llm-project-assistant/quickstart.md) with validation flow. +6. Decompose to execution tasks in [`tasks.md`](specs/021-llm-project-assistant/tasks.md) by user stories. + +## Complexity Tracking + +No constitution violations identified. + +## Test Data Reference + +| Component | TIER | Fixture Name | Location | +|---|---|---|---| +| Assistant intent parsing | CRITICAL | assistant_command_git_branch | [`spec.md`](specs/021-llm-project-assistant/spec.md) | +| Dangerous operation confirmation | CRITICAL | assistant_command_prod_deploy_confirmation | [`spec.md`](specs/021-llm-project-assistant/spec.md) | +| Task status query resolution | STANDARD | assistant_status_query | [`spec.md`](specs/021-llm-project-assistant/spec.md) | + +**Note**: Tester implementation should materialize these fixtures in backend/frontend test suites during `/speckit.tasks` execution. diff --git a/specs/021-llm-project-assistant/quickstart.md b/specs/021-llm-project-assistant/quickstart.md new file mode 100644 index 0000000..5bcd5fb --- /dev/null +++ b/specs/021-llm-project-assistant/quickstart.md @@ -0,0 +1,92 @@ +# Quickstart: LLM Chat Assistant for Project Operations + +## Purpose + +Implement and validate assistant-driven project operations defined in: +- Spec: [`spec.md`](specs/021-llm-project-assistant/spec.md) +- UX reference: [`ux_reference.md`](specs/021-llm-project-assistant/ux_reference.md) +- Plan: [`plan.md`](specs/021-llm-project-assistant/plan.md) +- Data model: [`data-model.md`](specs/021-llm-project-assistant/data-model.md) +- Contracts: [`contracts/modules.md`](specs/021-llm-project-assistant/contracts/modules.md), [`contracts/assistant-api.openapi.yaml`](specs/021-llm-project-assistant/contracts/assistant-api.openapi.yaml) + +## 1) Backend implementation flow + +1. Add assistant route module (`/api/assistant/*`). +2. Implement intent parser service (hybrid deterministic + LLM fallback). +3. Implement security guard and confirmation token workflow. +4. Implement execution adapter mapping intents to existing Git/migration/backup/LLM/tasks operations. +5. Add structured assistant audit logging. + +## 2) Frontend implementation flow + +1. Add assistant chat panel component and open/close controls in main layout. +2. Add assistant API client using existing frontend API wrapper. +3. Render assistant response states (`needs_confirmation`, `started`, `denied`, etc.). +4. Add confirm/cancel actions and task tracking chips linking to Task Drawer/reports. +5. Add i18n keys for RU/EN assistant labels and status texts. + +## 3) Safety checks (must pass) + +- Unauthorized command execution is denied. +- Dangerous operations require confirmation and do not auto-run. +- Expired/cancelled confirmation never executes. +- Confirm endpoint is idempotent (single execution). + +## 4) UX conformance checks (must pass) + +- Chat interaction available in app shell. +- Immediate "started" response with `task_id` for long jobs. +- Clear clarification prompt on ambiguous commands. +- Clear error text and recovery guidance on failed dispatch. + +## 5) Contract checks (must pass) + +- Assistant endpoints conform to [`assistant-api.openapi.yaml`](specs/021-llm-project-assistant/contracts/assistant-api.openapi.yaml). +- Response states always included and valid enum values. +- Confirmation flow returns deterministic machine-readable output. + +## 6) Suggested validation commands + +Backend tests: +```bash +cd backend && .venv/bin/python3 -m pytest +``` + +Frontend tests: +```bash +cd frontend && npm test +``` + +Targeted assistant tests (when implemented): +```bash +cd backend && .venv/bin/python3 -m pytest tests -k assistant +cd frontend && npm test -- assistant +``` + +## 7) Done criteria for planning handoff + +- All planning artifacts for `021` exist and are mutually consistent. +- Risk confirmation and RBAC enforcement are explicit in spec and contracts. +- Task-tracking integration through existing Task Drawer/reports is reflected in contracts and tasks. +- Ready for implementation via `tasks.md`. + +## 8) Validation Log (2026-02-23) + +### Frontend + +- `cd frontend && npx vitest run src/lib/components/assistant/__tests__/assistant_chat.integration.test.js src/lib/components/assistant/__tests__/assistant_confirmation.integration.test.js src/lib/stores/__tests__/assistantChat.test.js` + - Result: **PASS** (`11 passed`) +- `cd frontend && npm run build` + - Result: **PASS** (build successful; project has pre-existing non-blocking a11y warnings outside assistant scope) + +### Backend + +- `python3 -m py_compile backend/src/api/routes/assistant.py backend/src/models/assistant.py backend/src/api/routes/__tests__/test_assistant_api.py backend/src/api/routes/__tests__/test_assistant_authz.py` + - Result: **PASS** +- `cd backend && .venv/bin/python -m pytest -q src/api/routes/__tests__/test_assistant_api.py src/api/routes/__tests__/test_assistant_authz.py` + - Result: **BLOCKED in current env** (runtime hang during request execution after heavy app bootstrap; collection succeeds) + +### Semantics + +- `python3 generate_semantic_map.py` + - Result: **PASS** (updated semantic artifacts: `.ai/PROJECT_MAP.md`, `.ai/MODULE_MAP.md`, `semantics/semantic_map.json`) diff --git a/specs/021-llm-project-assistant/research.md b/specs/021-llm-project-assistant/research.md new file mode 100644 index 0000000..b4e141a --- /dev/null +++ b/specs/021-llm-project-assistant/research.md @@ -0,0 +1,97 @@ +# Phase 0 Research: LLM Chat Assistant for Project Operations + +**Feature**: [`021-llm-project-assistant`](specs/021-llm-project-assistant) +**Input Spec**: [`spec.md`](specs/021-llm-project-assistant/spec.md) +**Related UX**: [`ux_reference.md`](specs/021-llm-project-assistant/ux_reference.md) + +## 1) Intent parsing for operational commands (RU/EN) + +### Decision +Use hybrid parsing: +1. Lightweight deterministic recognizers for high-signal commands/entities (IDs, env names, keywords). +2. LLM interpretation for ambiguous/free-form phrasing. +3. Confidence threshold gate (`needs_clarification` when below threshold). + +### Rationale +Deterministic layer reduces hallucination risk for critical operations; LLM layer preserves natural-language flexibility. + +### Alternatives considered +- **Pure LLM parser only**: rejected due to unsafe variance for critical ops. +- **Pure regex/rules only**: rejected due to low language flexibility and high maintenance. + +--- + +## 2) Risk classification and confirmation policy + +### Decision +Introduce explicit risk levels per command template: +- `safe`: read/status operations. +- `guarded`: state-changing non-production operations. +- `dangerous`: production deploy/migration and similarly impactful actions. + +`dangerous` always requires explicit confirmation token before execution. + +### Rationale +This maps to user acceptance criteria and prevents accidental production-impact operations. + +### Alternatives considered +- **Prompt-only warning without hard confirmation gate**: rejected as insufficiently safe. +- **Confirmation for every command**: rejected due to severe UX friction. + +--- + +## 3) Execution integration strategy + +### Decision +Assistant dispatch calls existing internal services/endpoints instead of duplicating business logic. + +Target mappings: +- Git -> existing git routes/service methods. +- Migration/backup/LLM analysis -> task creation with existing plugin IDs. +- Status/report -> existing tasks/reports APIs. + +### Rationale +Reusing existing execution paths preserves permission checks and reduces regression risk. + +### Alternatives considered +- **New parallel execution engine**: rejected (duplicated logic, bypass risk). + +--- + +## 4) Confirmation token lifecycle + +### Decision +Confirmation token model with one-time usage + TTL + user binding. +- Token includes normalized command snapshot + risk metadata. +- Confirm executes exactly once. +- Expired/cancelled token cannot be reused. + +### Rationale +Prevents duplicate destructive execution and supports explicit audit trail. + +### Alternatives considered +- **Session flag confirmation without token**: rejected due to weak idempotency guarantees. + +--- + +## 5) Auditability and observability model + +### Decision +Log each assistant interaction as structured audit entry: +- actor, raw message, parsed intent, confidence, risk level, decision, dispatch target, outcome, linked `task_id` if any. + +### Rationale +Required for post-incident analysis and security traceability. + +### Alternatives considered +- **Log only successful executions**: rejected (misses denied/failed attempts and ambiguity events). + +--- + +## Consolidated Research Outcomes for Planning + +- Hybrid parser with confidence gating is selected. +- Risk-classified confirmation flow is mandatory for dangerous operations. +- Existing APIs/plugins are the only execution backends. +- One-time confirmation token with TTL is required for safety and idempotency. +- Structured assistant audit logging is required for operational trust. diff --git a/specs/021-llm-project-assistant/spec.md b/specs/021-llm-project-assistant/spec.md new file mode 100644 index 0000000..1fdf1cf --- /dev/null +++ b/specs/021-llm-project-assistant/spec.md @@ -0,0 +1,187 @@ +# Feature Specification: LLM Chat Assistant for Project Operations + +**Feature Branch**: `021-llm-project-assistant` +**Reference UX**: `ux_reference.md` (See specific folder) +**Created**: 2026-02-23 +**Status**: Draft +**Input**: User description: "Создать чат-ассистента на базе LLM в веб-интерфейсе, чтобы управлять Git, миграциями/бэкапами, LLM-анализом и статусами задач через команды на естественном языке." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Use chat instead of manual UI navigation (Priority: P1) + +As an operator, I can open an in-app chat and submit natural-language commands so I can trigger key actions without navigating multiple pages. + +**Why this priority**: Chat entrypoint is the core product value. Without it, no assistant workflow exists. + +**Independent Test**: Open assistant panel, send a command like "создай ветку feature-abc для дашборда 42", and verify that assistant returns a structured actionable response. + +**Acceptance Scenarios**: + +1. **Given** an authenticated user on any main page, **When** they open the assistant and send a message, **Then** the message appears in chat history and receives a response. +2. **Given** assistant is available, **When** the user asks for supported actions, **Then** assistant returns clear examples for Git, migration/backup, analysis, and task status queries. + +--- + +### User Story 2 - Execute core operations via natural language commands (Priority: P1) + +As an operator, I can ask assistant to execute core system operations (Git, migration/backup, LLM analysis/docs, status/report checks) so I can run workflows faster. + +**Why this priority**: Command execution across core modules defines feature completeness. + +**Independent Test**: Send one command per capability group and verify that the corresponding backend task/API action is initiated with valid parameters. + +**Acceptance Scenarios**: + +1. **Given** user command targets Git operation, **When** assistant parses intent and entities, **Then** system starts corresponding Git operation (branch/commit/deploy) or returns explicit validation error. +2. **Given** user command targets migration or backup, **When** assistant executes command, **Then** system starts async task and returns created `task_id` immediately. +3. **Given** user command targets LLM validation/documentation, **When** assistant executes command, **Then** system triggers the relevant LLM task/plugin and returns launch confirmation with `task_id`. +4. **Given** user asks task/report status, **When** assistant handles request, **Then** it returns latest known status and linkable reference to existing task tracking UI. + +--- + +### User Story 3 - Safe execution with RBAC and explicit confirmations (Priority: P1) + +As a system administrator, I need assistant actions to respect existing permissions and require explicit user confirmation for risky operations. + +**Why this priority**: Security and production safety are mandatory gate criteria. + +**Independent Test**: Run restricted command with unauthorized user (expect deny), and run production deploy command (expect confirm-before-execute flow). + +**Acceptance Scenarios**: + +1. **Given** user lacks permission for requested operation, **When** assistant tries to execute command, **Then** execution is denied and assistant returns permission error without side effects. +2. **Given** command is classified as dangerous (e.g., deploy to production), **When** assistant detects it, **Then** assistant requires explicit user confirmation before creating task. +3. **Given** confirmation is requested, **When** user cancels or confirmation expires, **Then** operation is not executed. + +--- + +### User Story 4 - Reliable feedback and progress tracking (Priority: P2) + +As an operator, I need immediate operation feedback, clear errors, and traceable progress for long-running tasks. + +**Why this priority**: Strong feedback loop is required for operational trust and usability. + +**Independent Test**: Launch long migration via chat and verify immediate "started" message with `task_id`, then check progress in Task Drawer and reports page. + +**Acceptance Scenarios**: + +1. **Given** assistant starts long operation, **When** execution begins, **Then** assistant responds immediately with initiation status and `task_id`. +2. **Given** operation succeeds or fails, **When** task result becomes available, **Then** assistant can return outcome summary (success/error) on user request. +3. **Given** assistant cannot parse command confidently, **When** ambiguity is detected, **Then** assistant asks clarification question and does not execute action. + +--- + +### Edge Cases + +- User command matches multiple possible operations (ambiguous intent). +- User references non-existent dashboard/environment/task IDs. +- Duplicate command submission (double-send) for destructive operations. +- Confirmation token expires before user confirms. +- Provider/LLM unavailable during command interpretation. +- Task creation succeeds but downstream execution fails immediately. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide an in-application chat interface for assistant interaction. +- **FR-002**: System MUST preserve chat context per user session for at least the active session duration. +- **FR-003**: Assistant MUST support natural-language command parsing for these domains: Git, migration/backup, LLM analysis/documentation, task/report status. +- **FR-004**: Assistant MUST map parsed commands to existing internal operations/APIs and MUST NOT bypass current service boundaries. +- **FR-005**: Assistant MUST support Git commands for at least: branch creation, commit initiation, deploy initiation. +- **FR-006**: Assistant MUST support migration and backup launch commands and return created `task_id`. +- **FR-007**: Assistant MUST support LLM validation/documentation launch commands and return created `task_id`. +- **FR-008**: Assistant MUST support status queries for existing tasks by `task_id` and by recent user scope. +- **FR-009**: Assistant MUST enforce existing RBAC/permission checks before any operation execution. +- **FR-010**: Assistant MUST classify risky operations and require explicit confirmation before execution. +- **FR-011**: Confirmation flow MUST include timeout/expiry semantics and explicit cancel path. +- **FR-012**: Assistant MUST provide immediate response for long-running operations containing `task_id` and a short tracking hint. +- **FR-013**: Assistant responses MUST include operation result state: `success`, `failed`, `started`, `needs_confirmation`, `needs_clarification`, or `denied`. +- **FR-014**: Assistant MUST surface actionable error details without exposing secrets. +- **FR-015**: Assistant MUST log every attempted assistant command with actor, intent, parameters snapshot, and outcome for auditability. +- **FR-016**: Assistant MUST prevent duplicate execution for the same pending confirmation token. +- **FR-017**: Assistant MUST support multilingual command input at minimum for Russian and English operational phrasing. +- **FR-018**: Assistant MUST degrade safely when intent confidence is below threshold by requesting clarification instead of executing. +- **FR-019**: Assistant MUST link users to existing progress surfaces (Task Drawer and reports page) for task tracking. +- **FR-020**: Assistant MUST support retrieval of last N executed assistant commands for operational traceability. + +### Key Entities *(include if feature involves data)* + +- **AssistantMessage**: One chat message (user or assistant) with timestamp, role, text, and metadata. +- **CommandIntent**: Parsed intent structure containing domain, operation, entities, confidence, and risk level. +- **ExecutionRequest**: Validated command payload mapped to a concrete backend action. +- **ConfirmationToken**: Pending confirmation record for risky operations with TTL and one-time usage. +- **AssistantExecutionLog**: Audit trail entry for command attempts and outcomes. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: At least 90% of predefined core commands (Git/migration/backup/LLM/status) are correctly interpreted in acceptance test set. +- **SC-002**: 100% of risky operations (production deploy and equivalent) require explicit confirmation before execution. +- **SC-003**: 100% of assistant-started long-running operations return a `task_id` in the first response. +- **SC-004**: Permission bypass rate is 0% in security tests (unauthorized commands never execute). +- **SC-005**: At least 95% of assistant responses return within 2 seconds for parse/dispatch stage (excluding downstream task runtime). +- **SC-006**: At least 90% of operators can launch a target operation from chat faster than through manual multi-page navigation in usability checks. + +--- + +## Assumptions + +- Existing APIs/plugins for Git, migration, backup, LLM actions, and task status remain authoritative execution backends. +- Existing RBAC permissions (`plugin:*`, `tasks:*`, `admin:*`) remain the access model. +- Task Drawer and reports page remain current progress/result surfaces and will be reused. +- LLM assistant orchestration can use configured provider stack without introducing a separate auth model. + +## Dependencies + +- Existing routes/services for Git (`/api/git/...`), migration (`/api/execute`), task APIs (`/api/tasks/...`), and LLM provider/task flows. +- Existing authentication and authorization components from multi-user auth implementation. +- Existing task manager persistence/logging for async execution tracking. + +--- + +## Test Data Fixtures *(recommended for CRITICAL components)* + +### Fixtures + +```yaml +assistant_command_git_branch: + description: "Valid RU command for branch creation" + data: + message: "сделай ветку feature/revenue-v2 для дашборда 42" + expected: + domain: "git" + operation: "create_branch" + entities: + dashboard_id: 42 + branch_name: "feature/revenue-v2" + +assistant_command_migration_start: + description: "Valid migration launch command" + data: + message: "запусти миграцию с dev на prod для дашборда 42" + expected: + domain: "migration" + operation: "execute" + requires_confirmation: true + +assistant_command_prod_deploy_confirmation: + description: "Risky production deploy requires confirmation" + data: + message: "задеплой дашборд 42 в production" + expected: + state: "needs_confirmation" + confirmation_required: true + +assistant_status_query: + description: "Task status lookup" + data: + message: "проверь статус задачи task-123" + expected: + domain: "status" + operation: "get_task" + entities: + task_id: "task-123" +``` diff --git a/specs/021-llm-project-assistant/tasks.md b/specs/021-llm-project-assistant/tasks.md new file mode 100644 index 0000000..db2a526 --- /dev/null +++ b/specs/021-llm-project-assistant/tasks.md @@ -0,0 +1,200 @@ +# Tasks: LLM Chat Assistant for Project Operations + +**Input**: Design documents from [`/specs/021-llm-project-assistant/`](specs/021-llm-project-assistant) +**Prerequisites**: [`plan.md`](specs/021-llm-project-assistant/plan.md), [`spec.md`](specs/021-llm-project-assistant/spec.md), [`ux_reference.md`](specs/021-llm-project-assistant/ux_reference.md), [`research.md`](specs/021-llm-project-assistant/research.md), [`data-model.md`](specs/021-llm-project-assistant/data-model.md), [`contracts/`](specs/021-llm-project-assistant/contracts) + +**Tests**: Include backend contract/integration tests and frontend integration/state tests. + +**Organization**: Tasks are grouped by user story for independent implementation and validation. + +## Format: `[ID] [P?] [Story] Description` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare assistant feature scaffolding and fixtures. + +- [ ] T001 Create assistant feature folders in `backend/src/services/assistant/`, `frontend/src/lib/components/assistant/`, `frontend/src/lib/api/assistant.js` +- [ ] T002 [P] Add backend fixtures for assistant parsing/confirmation in `backend/tests/fixtures/assistant/fixtures_assistant.json` +- [ ] T003 [P] Add frontend fixtures for assistant response states in `frontend/src/lib/components/assistant/__tests__/fixtures/assistant.fixtures.js` +- [x] T004 [P] Add i18n placeholders for assistant UI in `frontend/src/lib/i18n/locales/en.json` and `frontend/src/lib/i18n/locales/ru.json` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Build core assistant architecture required by all stories. + +- [x] T005 Implement assistant domain models (`AssistantMessage`, `CommandIntent`, `ConfirmationToken`, `AssistantExecutionLog`) in `backend/src/models/assistant.py` +- [ ] T006 [P] Implement intent parser service with confidence output in `backend/src/services/assistant/intent_parser.py` +- [ ] T007 Implement RBAC + risk guard service in `backend/src/services/assistant/security_guard.py` +- [ ] T008 Implement execution adapter to existing operations in `backend/src/services/assistant/execution_adapter.py` +- [ ] T009 Implement assistant orchestrator service in `backend/src/services/assistant/orchestrator.py` +- [ ] T010 [P] Implement assistant audit logging service/repository in `backend/src/services/assistant/audit_service.py` +- [x] T011 Create assistant API route module in `backend/src/api/routes/assistant.py` (CRITICAL: message/confirm/cancel/history endpoints) +- [x] T012 Wire assistant router into `backend/src/api/routes/__init__.py` and `backend/src/app.py` +- [x] T013 Create frontend assistant API client in `frontend/src/lib/api/assistant.js` +- [ ] T014 Verify contracts alignment between `spec.md` and `contracts/modules.md` + +**Checkpoint**: Foundation complete; user stories can begin. + +--- + +## Phase 3: User Story 1 - Use chat instead of manual UI navigation (Priority: P1) 🎯 MVP + +**Goal**: Add in-app assistant chat interface with working request/response loop. + +**Independent Test**: Open assistant panel and complete one command-response interaction. + +### Tests for User Story 1 + +- [x] T015 [P] [US1] Add backend API contract tests for `POST /api/assistant/messages` in `backend/src/api/routes/__tests__/test_assistant_api.py` +- [x] T016 [P] [US1] Add frontend integration test for chat panel interaction in `frontend/src/lib/components/assistant/__tests__/assistant_chat.integration.test.js` + +### Implementation for User Story 1 + +- [x] T017 [US1] Implement assistant chat panel component in `frontend/src/lib/components/assistant/AssistantChatPanel.svelte` +- [x] T018 [US1] Add assistant launch trigger to top navigation in `frontend/src/lib/components/layout/TopNavbar.svelte` +- [x] T019 [US1] Add chat state store in `frontend/src/lib/stores/assistantChat.js` +- [x] T020 [US1] Implement message submission and rendering pipeline in `AssistantChatPanel.svelte` with explicit state badges +- [x] T021 [US1] Add command examples/help section in `AssistantChatPanel.svelte` +- [x] T022 [US1] Validate UX states from [`ux_reference.md`](specs/021-llm-project-assistant/ux_reference.md) + +**Checkpoint**: US1 independently functional and demo-ready. + +--- + +## Phase 4: User Story 2 - Execute core operations via natural-language commands (Priority: P1) + +**Goal**: Support command parsing and dispatch for Git/migration/backup/LLM/status operations. + +**Independent Test**: One command per domain executes correct backend path or returns valid error. + +### Tests for User Story 2 + +- [ ] T023 [P] [US2] Add parser tests for RU/EN command variants in `backend/src/services/assistant/__tests__/test_intent_parser.py` +- [ ] T024 [P] [US2] Add execution adapter integration tests in `backend/src/services/assistant/__tests__/test_execution_adapter.py` +- [ ] T025 [P] [US2] Add frontend state tests for command result rendering in `frontend/src/lib/components/assistant/__tests__/assistant_states.test.js` + +### Implementation for User Story 2 + +- [ ] T026 [US2] Implement Git command mappings in `execution_adapter.py` (create branch, commit, deploy) +- [ ] T027 [US2] Implement migration/backup command mappings in `execution_adapter.py` via existing task creation +- [ ] T028 [US2] Implement LLM validation/documentation command mappings in `execution_adapter.py` +- [ ] T029 [US2] Implement task/report status query mappings in `execution_adapter.py` +- [ ] T030 [US2] Implement confidence gate and clarification responses in `orchestrator.py` +- [x] T031 [US2] Render started/success/failed response cards with `task_id` chips in `AssistantChatPanel.svelte` +- [x] T032 [US2] Add quick action from `task_id` chip to Task Drawer in `frontend/src/lib/stores/taskDrawer.js` integration points + +**Checkpoint**: US2 independently functional across requested command domains. + +--- + +## Phase 5: User Story 3 - Safe execution with RBAC and explicit confirmations (Priority: P1) + +**Goal**: Enforce permissions and confirmation gates for dangerous operations. + +**Independent Test**: Unauthorized command denied; production deploy requires confirm and executes only after confirm. + +### Tests for User Story 3 + +- [x] T033 [P] [US3] Add backend authorization tests in `backend/src/api/routes/__tests__/test_assistant_authz.py` +- [ ] T034 [P] [US3] Add backend confirmation lifecycle tests in `backend/src/services/assistant/__tests__/test_confirmation_flow.py` +- [x] T035 [P] [US3] Add frontend confirm/cancel UX tests in `frontend/src/lib/components/assistant/__tests__/assistant_confirmation.integration.test.js` + +### Implementation for User Story 3 + +- [ ] T036 [US3] Implement risk policy matrix in `backend/src/services/assistant/risk_policy.py` +- [ ] T037 [US3] Implement confirmation token storage/service in `backend/src/services/assistant/confirmation_service.py` +- [x] T038 [US3] Implement confirm/cancel endpoints in `backend/src/api/routes/assistant.py` +- [ ] T039 [US3] Enforce one-time token consumption and TTL checks in `confirmation_service.py` +- [x] T040 [US3] Add confirmation UI card with explicit operation summary in `AssistantChatPanel.svelte` +- [x] T041 [US3] Surface `denied` and `needs_confirmation` assistant states consistently in UI + +**Checkpoint**: US3 independently functional with enforced safety controls. + +--- + +## Phase 6: User Story 4 - Reliable feedback and progress tracking (Priority: P2) + +**Goal**: Provide immediate task start feedback and robust status/report follow-up. + +**Independent Test**: Long operation returns `task_id` immediately and can be tracked via Task Drawer/reports. + +### Tests for User Story 4 + +- [ ] T042 [P] [US4] Add backend tests for started response payload and task linkage in `backend/src/api/routes/__tests__/test_assistant_task_feedback.py` +- [ ] T043 [P] [US4] Add frontend integration tests for tracking links/actions in `frontend/src/lib/components/assistant/__tests__/assistant_tracking.integration.test.js` + +### Implementation for User Story 4 + +- [ ] T044 [US4] Add response formatter service for consistent assistant states in `backend/src/services/assistant/response_formatter.py` +- [x] T045 [US4] Implement history endpoint pagination in `backend/src/api/routes/assistant.py` +- [ ] T046 [US4] Implement history loading and pagination in `frontend/src/lib/stores/assistantChat.js` +- [x] T047 [US4] Add open-reports quick action for completed/failed tasks in `AssistantChatPanel.svelte` +- [ ] T048 [US4] Add structured error mapping and user guidance messages in `response_formatter.py` and frontend render logic + +**Checkpoint**: US4 independently functional with complete feedback loop. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Final quality, compliance, and operational readiness. + +- [ ] T049 [P] Add OpenAPI conformance tests against `specs/021-llm-project-assistant/contracts/assistant-api.openapi.yaml` in `backend/src/api/routes/__tests__/test_assistant_openapi_conformance.py` +- [ ] T050 [P] Add audit log persistence tests in `backend/src/services/assistant/__tests__/test_audit_service.py` +- [ ] T051 [P] Add frontend performance tests for chat rendering with long history in `frontend/src/lib/components/assistant/__tests__/assistant_performance.test.js` +- [ ] T052 Update operational docs in `docs/settings.md` and `README.md` for assistant usage and safety confirmations +- [x] T053 Run quickstart validation and record results in `specs/021-llm-project-assistant/quickstart.md` +- [ ] T054 Run semantic map generation (`python3 generate_semantic_map.py`) and resolve critical issues in assistant modules + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phase 1 has no dependencies. +- Phase 2 depends on Phase 1 and blocks all user stories. +- Phases 3-6 depend on Phase 2. +- Phase 7 depends on completion of selected user stories. + +### User Story Dependency Graph + +- **US1 (P1)**: MVP chat entrypoint. +- **US2 (P1)**: Command execution capabilities. +- **US3 (P1)**: Safety and security controls. +- **US4 (P2)**: Feedback and tracking depth. + +Graph: `US1 -> {US2, US3} -> US4` + +### Parallel Opportunities + +- Setup fixture/i18n tasks: T002, T003, T004. +- Foundational tasks: T006/T010 can run in parallel after T005 baseline. +- Story tests in each phase marked `[P]` can run in parallel. +- US2 domain mappings (T026-T029) can be split across developers. + +--- + +## Implementation Strategy + +### MVP First (Recommended) + +1. Complete Phase 1 + Phase 2. +2. Complete Phase 3 (US1). +3. Complete minimal subset of US2 for one domain (e.g., migration + status) and validate. + +### Incremental Delivery + +1. Deliver US1 chat shell. +2. Add US2 command domains iteratively. +3. Add US3 confirmation and RBAC hardening. +4. Add US4 history/tracking refinements. +5. Finish Phase 7 compliance and docs. + +### UX Preservation Rule + +No task should degrade clarity or safety defined in [`ux_reference.md`](specs/021-llm-project-assistant/ux_reference.md). diff --git a/specs/021-llm-project-assistant/ux_reference.md b/specs/021-llm-project-assistant/ux_reference.md new file mode 100644 index 0000000..7ce4a52 --- /dev/null +++ b/specs/021-llm-project-assistant/ux_reference.md @@ -0,0 +1,84 @@ +# UX Reference: LLM Chat Assistant for Project Operations + +**Feature Branch**: `021-llm-project-assistant` +**Created**: 2026-02-23 +**Status**: Draft + +## 1. User Persona & Context + +* **Who is the user?**: Platform operator, dashboard engineer, or admin managing Superset operations. +* **What is their goal?**: Execute routine and critical project operations quickly via natural language with safe controls. +* **Context**: User is already in `ss-tools` web interface and wants to avoid jumping between Git, migration, backup, and task pages. + +## 2. The "Happy Path" Narrative + +The user opens assistant chat from the main interface and enters a natural command to run an operation. Assistant immediately recognizes intent, validates permissions, and either executes directly or asks for confirmation if operation is risky. For long-running actions, assistant responds instantly with `task_id` and where to track progress. User can ask follow-up status questions in the same chat and receives concise operational answers. + +## 3. Interface Mockups + +### UI Layout & Flow (if applicable) + +**Screen/Component**: Assistant Chat Panel + +* **Layout**: + - Right-side drawer or modal panel with message history. + - Input area at bottom with send action and command examples shortcut. + - Message cards with role (User/Assistant), timestamp, state badge. +* **Key Elements**: + * **Chat Input**: Supports free-text commands in RU/EN. + * **State Badge**: `started`, `success`, `failed`, `needs_confirmation`, `denied`. + * **Confirmation Card**: Explicit confirm/cancel buttons for risky actions. + * **Task Link Chip**: `task_id` chip with quick open in Task Drawer. + * **Suggestions Block**: "Try command" examples for supported domains. +* **States**: + * **Default**: Empty or recent history shown, examples visible. + * **Parsing**: Inline "Analyzing command..." loading state. + * **Needs Confirmation**: Warning card with operation summary and target environment. + * **Started**: Immediate message with `task_id` and tracking hint. + * **Error**: Error card with reason and recovery suggestion. + +### Interaction Examples + +```text +User: запусти миграцию с dev на prod для дашборда 42 +Assistant: Операция рискованная (target=production). Подтвердите запуск. +[Confirm] [Cancel] + +User: confirm +Assistant: Миграция запущена. task_id=task-8f13. Прогресс: Task Drawer / Reports. +``` + +## 4. The "Error" Experience + +**Philosophy**: Never silently fail. Show what happened, why, and what to do next. + +### Scenario A: Ambiguous Command + +* **User Action**: "сделай задачу для дашборда 42" +* **System Response**: + * Assistant does not execute immediately. + * Returns clarification question with options (Git branch / migration / backup / analysis). +* **Recovery**: User selects one option or reformulates command. + +### Scenario B: Permission Denied + +* **User Action**: Non-admin requests production deploy. +* **System Response**: + * Assistant returns `denied` state with minimal permission reason. + * No task is created. +* **Recovery**: User requests access or uses allowed operation. + +### Scenario C: Confirmation Timeout + +* **System Response**: "Confirmation expired. Operation was not executed." +* **Recovery**: User reissues command. + +### Scenario D: Downstream Failure After Start + +* **System Response**: Assistant returns `task_id` and indicates task moved to failed state with short error summary. +* **Recovery**: Open task logs/reports using provided link/action. + +## 5. Tone & Voice + +* **Style**: Operational, concise, explicit. +* **Terminology**: Use "command", "task", "confirmation", "status", "task_id"; avoid vague wording.