diff --git a/.kilocode/workflows/speckit.plan.md b/.kilocode/workflows/speckit.plan.md index f44a590..3c3be2f 100644 --- a/.kilocode/workflows/speckit.plan.md +++ b/.kilocode/workflows/speckit.plan.md @@ -64,18 +64,32 @@ You **MUST** consider the user input before proceeding (if not empty). **Prerequisites:** `research.md` complete +0. **Validate Design against UX Reference**: + - Check if the proposed architecture supports the latency, interactivity, and flow defined in `ux_reference.md`. + - **Linkage**: Ensure key UI states from `ux_reference.md` map to Component Contracts (`@UX_STATE`). + - **CRITICAL**: If the technical plan compromises the UX (e.g. "We can't do real-time validation"), you **MUST STOP** and warn the user. + 1. **Extract entities from feature spec** → `data-model.md`: - - Entity name, fields, relationships - - Validation rules from requirements - - State transitions if applicable + - Entity name, fields, relationships, validation rules. -2. **Define interface contracts** (if project has external interfaces) → `/contracts/`: - - Identify what interfaces the project exposes to users or other systems - - Document the contract format appropriate for the project type - - Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications - - Skip if project is purely internal (build scripts, one-off tools, etc.) +2. **Design & Verify Contracts (Semantic Protocol)**: + - **Drafting**: Define [DEF] Headers and Contracts for all new modules based on `.ai/standards/semantics.md`. + - **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module. + - **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts. + - **Self-Review**: + - *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research? + - *Connectivity*: Do `@RELATION` tags form a coherent graph? + - *Compliance*: Does syntax match `[DEF:id:Type]` exactly? + - **Output**: Write verified contracts to `contracts/modules.md`. -3. **Agent context update**: +3. **Simulate Contract Usage**: + - Trace one key user scenario through the defined contracts to ensure data flow continuity. + - If a contract interface mismatch is found, fix it immediately. + +4. **Generate API contracts**: + - Output OpenAPI/GraphQL schema to `/contracts/` for backend-frontend sync. + +5. **Agent context update**: - Run `.specify/scripts/bash/update-agent-context.sh kilocode` - These scripts detect which AI agent is in use - Update the appropriate agent-specific context file diff --git a/backend/src/api/routes/__tests__/test_assistant_api.py b/backend/src/api/routes/__tests__/test_assistant_api.py index dae6fd8..9ca7b42 100644 --- a/backend/src/api/routes/__tests__/test_assistant_api.py +++ b/backend/src/api/routes/__tests__/test_assistant_api.py @@ -392,11 +392,11 @@ def test_status_query_without_task_id_returns_latest_user_task(): # [/DEF:test_status_query_without_task_id_returns_latest_user_task:Function] -# [DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function] -# @PURPOSE: LLM validation command without resolvable dashboard id must request clarification instead of generic failure. -# @PRE: Command intent resolves to run_llm_validation but dashboard id cannot be inferred. -# @POST: Assistant response state is needs_clarification with guidance text. -def test_llm_validation_missing_dashboard_returns_needs_clarification(): +# [DEF:test_llm_validation_with_dashboard_ref_requires_confirmation:Function] +# @PURPOSE: LLM validation with dashboard_ref should now require confirmation before dispatch. +# @PRE: User sends natural-language validation request with dashboard name (not numeric id). +# @POST: Response state is needs_confirmation since all state-changing operations are now gated. +def test_llm_validation_with_dashboard_ref_requires_confirmation(): _clear_assistant_state() response = _run_async( assistant_module.send_message( @@ -410,8 +410,11 @@ def test_llm_validation_missing_dashboard_returns_needs_clarification(): ) ) - assert response.state == "needs_clarification" - assert "Укажите" in response.text or "Missing dashboard_id" in response.text + assert response.state == "needs_confirmation" + assert response.confirmation_id is not None + action_types = {a.type for a in response.actions} + assert "confirm" in action_types + assert "cancel" in action_types # [/DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function] @@ -555,4 +558,71 @@ def test_list_conversations_archived_only_filters_active(): # [/DEF:test_list_conversations_archived_only_filters_active:Function] + + +# [DEF:test_guarded_operation_always_requires_confirmation:Function] +# @PURPOSE: Non-dangerous (guarded) commands must still require confirmation before execution. +# @PRE: Admin user sends a backup command that was previously auto-executed. +# @POST: Response state is needs_confirmation with confirm and cancel actions. +def test_guarded_operation_always_requires_confirmation(): + _clear_assistant_state() + response = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message="сделай бэкап окружения dev" + ), + current_user=_admin_user(), + task_manager=_FakeTaskManager(), + config_manager=_FakeConfigManager(), + db=_FakeDb(), + ) + ) + assert response.state == "needs_confirmation" + assert response.confirmation_id is not None + action_types = {a.type for a in response.actions} + assert "confirm" in action_types + assert "cancel" in action_types + assert "Выполнить" in response.text or "Подтвердите" in response.text + + +# [/DEF:test_guarded_operation_always_requires_confirmation:Function] + + +# [DEF:test_guarded_operation_confirm_roundtrip:Function] +# @PURPOSE: Guarded operation must execute successfully after explicit confirmation. +# @PRE: Admin user sends a non-dangerous migration command (dev → dev). +# @POST: After confirmation, response transitions to started/success with task_id. +def test_guarded_operation_confirm_roundtrip(): + _clear_assistant_state() + task_manager = _FakeTaskManager() + db = _FakeDb() + + first = _run_async( + assistant_module.send_message( + request=assistant_module.AssistantMessageRequest( + message="запусти миграцию с dev на dev для дашборда 5" + ), + 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 is not None + + +# [/DEF:test_guarded_operation_confirm_roundtrip:Function] # [/DEF:backend.src.api.routes.__tests__.test_assistant_api:Module] diff --git a/backend/src/api/routes/assistant.py b/backend/src/api/routes/assistant.py index b30d79c..119c1b9 100644 --- a/backend/src/api/routes/assistant.py +++ b/backend/src/api/routes/assistant.py @@ -918,6 +918,46 @@ def _coerce_intent_entities(intent: Dict[str, Any]) -> Dict[str, Any]: # [/DEF:_coerce_intent_entities:Function] +# Operations that are read-only and do not require confirmation. +_SAFE_OPS = {"show_capabilities", "get_task_status"} + + +# [DEF:_confirmation_summary:Function] +# @PURPOSE: Build human-readable confirmation prompt for an intent before execution. +# @PRE: intent contains operation and entities fields. +# @POST: Returns descriptive Russian-language text ending with confirmation prompt. +def _confirmation_summary(intent: Dict[str, Any]) -> str: + operation = intent.get("operation", "") + entities = intent.get("entities", {}) + descriptions: Dict[str, str] = { + "create_branch": "создание ветки{branch} для дашборда{dashboard}", + "commit_changes": "коммит изменений для дашборда{dashboard}", + "deploy_dashboard": "деплой дашборда{dashboard} в окружение{env}", + "execute_migration": "миграция дашборда{dashboard} с{src} на{tgt}", + "run_backup": "бэкап окружения{env}{dashboard}", + "run_llm_validation": "LLM-валидация дашборда{dashboard}{env}", + "run_llm_documentation": "генерация документации для датасета{dataset}{env}", + } + template = descriptions.get(operation) + if not template: + return "Подтвердите выполнение операции или отмените." + + def _label(value: Any, prefix: str = " ") -> str: + return f"{prefix}{value}" if value else "" + + dashboard = entities.get("dashboard_id") or entities.get("dashboard_ref") + text = template.format( + branch=_label(entities.get("branch_name")), + dashboard=_label(dashboard), + env=_label(entities.get("environment") or entities.get("target_env")), + src=_label(entities.get("source_env")), + tgt=_label(entities.get("target_env")), + dataset=_label(entities.get("dataset_id")), + ) + return f"Выполнить: {text}. Подтвердите или отмените." +# [/DEF:_confirmation_summary:Function] + + # [DEF:_clarification_text_for_intent:Function] # @PURPOSE: Convert technical missing-parameter errors into user-facing clarification prompts. # @PRE: state was classified as needs_clarification for current intent/error combination. @@ -1328,14 +1368,7 @@ async def send_message( except Exception as exc: logger.warning(f"[assistant.planner][fallback] Planner error: {exc}") if not intent: - intent = { - "domain": "unknown", - "operation": "clarify", - "entities": {}, - "confidence": 0.0, - "risk_level": "safe", - "requires_confirmation": False, - } + intent = _parse_command(request.message, config_manager) confidence = float(intent.get("confidence", 0.0)) if intent.get("domain") == "unknown" or confidence < 0.6: @@ -1358,7 +1391,8 @@ async def send_message( try: _authorize_intent(intent, current_user) - if intent.get("requires_confirmation"): + operation = intent.get("operation") + if operation not in _SAFE_OPS: confirmation_id = str(uuid.uuid4()) confirm = ConfirmationRecord( id=confirmation_id, @@ -1371,7 +1405,7 @@ async def send_message( ) CONFIRMATIONS[confirmation_id] = confirm _persist_confirmation(db, confirm) - text = "Операция рискованная. Подтвердите выполнение или отмените." + text = _confirmation_summary(intent) _append_history( user_id, conversation_id, @@ -1388,7 +1422,13 @@ async def send_message( text, state="needs_confirmation", confirmation_id=confirmation_id, - metadata={"intent": intent}, + metadata={ + "intent": intent, + "actions": [ + {"type": "confirm", "label": "✅ Подтвердить", "target": confirmation_id}, + {"type": "cancel", "label": "❌ Отменить", "target": confirmation_id}, + ], + }, ) audit_payload = { "decision": "needs_confirmation", @@ -1406,12 +1446,13 @@ async def send_message( intent=intent, confirmation_id=confirmation_id, actions=[ - AssistantAction(type="confirm", label="Confirm", target=confirmation_id), - AssistantAction(type="cancel", label="Cancel", target=confirmation_id), + AssistantAction(type="confirm", label="✅ Подтвердить", target=confirmation_id), + AssistantAction(type="cancel", label="❌ Отменить", target=confirmation_id), ], created_at=datetime.utcnow(), ) + # Read-only operations execute immediately 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) diff --git a/backend/src/api/routes/dashboards.py b/backend/src/api/routes/dashboards.py index 59e0b94..69d2922 100644 --- a/backend/src/api/routes/dashboards.py +++ b/backend/src/api/routes/dashboards.py @@ -88,6 +88,20 @@ class DashboardDetailResponse(BaseModel): dataset_count: int # [/DEF:DashboardDetailResponse:DataClass] +# [DEF:DatabaseMapping:DataClass] +class DatabaseMapping(BaseModel): + source_db: str + target_db: str + source_db_uuid: Optional[str] = None + target_db_uuid: Optional[str] = None + confidence: float +# [/DEF:DatabaseMapping:DataClass] + +# [DEF:DatabaseMappingsResponse:DataClass] +class DatabaseMappingsResponse(BaseModel): + mappings: List[DatabaseMapping] +# [/DEF:DatabaseMappingsResponse:DataClass] + # [DEF:get_dashboards:Function] # @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status # @PRE: env_id must be a valid environment ID @@ -168,12 +182,54 @@ async def get_dashboards( raise HTTPException(status_code=503, detail=f"Failed to fetch dashboards: {str(e)}") # [/DEF:get_dashboards:Function] +# [DEF:get_database_mappings:Function] +# @PURPOSE: Get database mapping suggestions between source and target environments +# @PRE: User has permission plugin:migration:read +# @PRE: source_env_id and target_env_id are valid environment IDs +# @POST: Returns list of suggested database mappings with confidence scores +# @PARAM: source_env_id (str) - Source environment ID +# @PARAM: target_env_id (str) - Target environment ID +# @RETURN: DatabaseMappingsResponse - List of suggested mappings +# @RELATION: CALLS -> MappingService.get_suggestions +@router.get("/db-mappings", response_model=DatabaseMappingsResponse) +async def get_database_mappings( + source_env_id: str, + target_env_id: str, + mapping_service=Depends(get_mapping_service), + _ = Depends(has_permission("plugin:migration", "READ")) +): + with belief_scope("get_database_mappings", f"source={source_env_id}, target={target_env_id}"): + try: + # Get mapping suggestions using MappingService + suggestions = await mapping_service.get_suggestions(source_env_id, target_env_id) + + # Format suggestions as DatabaseMapping objects + mappings = [ + DatabaseMapping( + source_db=s.get('source_db', ''), + target_db=s.get('target_db', ''), + source_db_uuid=s.get('source_db_uuid'), + target_db_uuid=s.get('target_db_uuid'), + confidence=s.get('confidence', 0.0) + ) + for s in suggestions + ] + + logger.info(f"[get_database_mappings][Coherence:OK] Returning {len(mappings)} database mapping suggestions") + + return DatabaseMappingsResponse(mappings=mappings) + + except Exception as e: + logger.error(f"[get_database_mappings][Coherence:Failed] Failed to get database mappings: {e}") + raise HTTPException(status_code=503, detail=f"Failed to get database mappings: {str(e)}") +# [/DEF:get_database_mappings:Function] + # [DEF:get_dashboard_detail:Function] # @PURPOSE: Fetch detailed dashboard info with related charts and datasets # @PRE: env_id must be valid and dashboard_id must exist # @POST: Returns dashboard detail payload for overview page # @RELATION: CALLS -> SupersetClient.get_dashboard_detail -@router.get("/{dashboard_id}", response_model=DashboardDetailResponse) +@router.get("/{dashboard_id:int}", response_model=DashboardDetailResponse) async def get_dashboard_detail( dashboard_id: int, env_id: str, @@ -337,60 +393,4 @@ async def backup_dashboards( raise HTTPException(status_code=503, detail=f"Failed to create backup task: {str(e)}") # [/DEF:backup_dashboards:Function] -# [DEF:DatabaseMapping:DataClass] -class DatabaseMapping(BaseModel): - source_db: str - target_db: str - source_db_uuid: Optional[str] = None - target_db_uuid: Optional[str] = None - confidence: float -# [/DEF:DatabaseMapping:DataClass] - -# [DEF:DatabaseMappingsResponse:DataClass] -class DatabaseMappingsResponse(BaseModel): - mappings: List[DatabaseMapping] -# [/DEF:DatabaseMappingsResponse:DataClass] - -# [DEF:get_database_mappings:Function] -# @PURPOSE: Get database mapping suggestions between source and target environments -# @PRE: User has permission plugin:migration:read -# @PRE: source_env_id and target_env_id are valid environment IDs -# @POST: Returns list of suggested database mappings with confidence scores -# @PARAM: source_env_id (str) - Source environment ID -# @PARAM: target_env_id (str) - Target environment ID -# @RETURN: DatabaseMappingsResponse - List of suggested mappings -# @RELATION: CALLS -> MappingService.get_suggestions -@router.get("/db-mappings", response_model=DatabaseMappingsResponse) -async def get_database_mappings( - source_env_id: str, - target_env_id: str, - mapping_service=Depends(get_mapping_service), - _ = Depends(has_permission("plugin:migration", "READ")) -): - with belief_scope("get_database_mappings", f"source={source_env_id}, target={target_env_id}"): - try: - # Get mapping suggestions using MappingService - suggestions = await mapping_service.get_suggestions(source_env_id, target_env_id) - - # Format suggestions as DatabaseMapping objects - mappings = [ - DatabaseMapping( - source_db=s.get('source_db', ''), - target_db=s.get('target_db', ''), - source_db_uuid=s.get('source_db_uuid'), - target_db_uuid=s.get('target_db_uuid'), - confidence=s.get('confidence', 0.0) - ) - for s in suggestions - ] - - logger.info(f"[get_database_mappings][Coherence:OK] Returning {len(mappings)} database mapping suggestions") - - return DatabaseMappingsResponse(mappings=mappings) - - except Exception as e: - logger.error(f"[get_database_mappings][Coherence:Failed] Failed to get database mappings: {e}") - raise HTTPException(status_code=503, detail=f"Failed to get database mappings: {str(e)}") -# [/DEF:get_database_mappings:Function] - # [/DEF:backend.src.api.routes.dashboards:Module] diff --git a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte index 9327563..581fe76 100644 --- a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte +++ b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte @@ -644,7 +644,12 @@