This commit is contained in:
2026-02-25 10:34:30 +03:00
parent bc0367ab72
commit d32d85556f
5 changed files with 217 additions and 87 deletions

View File

@@ -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]

View File

@@ -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)

View File

@@ -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]