Ctrl K
ComunidadGuías…

tests/integration/test_pipeline.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Tests d'integration pour le Pipeline RAG.

Ces tests verifient le workflow complet:
1. Detection type query
2. RAG retrieval (mocke)
3. EPHAISTOS analyse (mocke)
4. LYRA dialogue (mocke)
5. HESTIA execution (mocke)
6. Multi-tour avec session memory
"""

from unittest.mock import Mock

import pytest

from lyra.core.config import RAGConfig
from lyra.core.pipeline import Pipeline, QueryType
from lyra.hestia.executor import ExecutionResult
from lyra.models.ephaistos import EphaistosAnalysis
from lyra.models.lyra_voice import LyraResponse


@pytest.fixture
def mock_config():
    """Configuration mockee pour les tests."""
    config = RAGConfig()
    config.llm = {"base_url": "http://localhost:11434", "timeout": 120}
    config.mcp = {"servers": {}}
    return config


@pytest.fixture
def pipeline_with_mocks(mock_config):
    """Pipeline avec tous les composants mockes."""
    pipeline = Pipeline(mock_config)

    # Mock les composants
    pipeline._retriever = Mock()
    pipeline._model_manager = Mock()
    pipeline._ephaistos = Mock()
    # _compact_spec est appele par le pipeline (filtrer_missing_args) : un Mock casse re.split
    pipeline._ephaistos._compact_spec.side_effect = lambda spec: spec
    pipeline._lyra = Mock()
    pipeline._hestia = Mock()

    # Session memory reelle pour tester le multi-tour
    from lyra.rag.session_memory import SessionMemory
    pipeline._sessions["default"] = SessionMemory(max_turns=10)

    pipeline._initialized = True

    # Creer WorkflowContext avec les mocks

    # Disable rule-based detect so EPHAISTOS mocks are used
    pipeline._rule_based_detect = lambda q: None

    # Configure HESTIA mock for VM status calls used in vm_clone workflow
    # Use DEFAULT to fall through to return_value for non-vm_status calls
    from unittest.mock import DEFAULT as MOCK_DEFAULT

    from lyra.hestia.executor import ExecutionResult
    def _mock_hestia_execute(tool_name, arguments=None, **kwargs):
        arguments = arguments or {}
        if "vm_status" in tool_name and not arguments.get("vm_name"):
            return ExecutionResult(success=True, content="preprod-09  stopped  -  -\npreprod-02  stopped  -  -\n")
        if "vm_status" in tool_name and arguments.get("vm_name") == "preprod-09":
            return ExecutionResult(success=True, content="Status: Arretee\n")
        return MOCK_DEFAULT  # Fall through to Mock's return_value
    pipeline._hestia.execute = Mock(side_effect=_mock_hestia_execute)
    pipeline._hestia.execute.return_value = ExecutionResult(success=True, content="")

    return pipeline


class TestQueryTypeDetection:
    """Tests de detection du type de requete."""

    def test_detect_knowledge_quoi(self, mock_config):
        """Detecte 'c'est quoi' comme knowledge."""
        pipeline = Pipeline(mock_config)
        assert pipeline.detect_query_type("c'est quoi vm_start") == QueryType.KNOWLEDGE

    def test_detect_knowledge_comment(self, mock_config):
        """Detecte 'comment' comme knowledge."""
        pipeline = Pipeline(mock_config)
        assert pipeline.detect_query_type("comment fonctionne le backup") == QueryType.KNOWLEDGE

    def test_detect_knowledge_quest_ce_que(self, mock_config):
        """Detecte 'qu'est-ce que' comme knowledge."""
        pipeline = Pipeline(mock_config)
        assert pipeline.detect_query_type("qu'est-ce que vm_clone") == QueryType.KNOWLEDGE

    def test_detect_action_demarre(self, mock_config):
        """Detecte 'demarre' comme action."""
        pipeline = Pipeline(mock_config)
        assert pipeline.detect_query_type("demarre preprod-09") == QueryType.ACTION

    def test_detect_action_clone(self, mock_config):
        """Detecte 'clone' comme action."""
        pipeline = Pipeline(mock_config)
        assert pipeline.detect_query_type("clone ma VM") == QueryType.ACTION

    def test_detect_action_backup(self, mock_config):
        """Detecte 'backup' comme action."""
        pipeline = Pipeline(mock_config)
        assert pipeline.detect_query_type("backup preprod-09") == QueryType.ACTION

    def test_detect_action_liste(self, mock_config):
        """Detecte 'liste' comme action."""
        pipeline = Pipeline(mock_config)
        assert pipeline.detect_query_type("liste les VMs") == QueryType.ACTION

    def test_detect_action_default(self, mock_config):
        """Par defaut, detecte comme action."""
        pipeline = Pipeline(mock_config)
        assert pipeline.detect_query_type("preprod-09 status") == QueryType.ACTION

    def test_detect_action_fais(self, mock_config):
        """Detecte 'fais' comme action."""
        pipeline = Pipeline(mock_config)
        assert pipeline.detect_query_type("fais un backup de test-vm") == QueryType.ACTION


class TestActionWorkflow:
    """Tests du workflow action complet."""

    def test_action_with_complete_args(self, pipeline_with_mocks):
        """Test action avec arguments complets."""
        pipeline = pipeline_with_mocks

        # Mock RAG results
        mock_fused = Mock()
        mock_fused.document = "vm_start(vm_name: string) - Demarre une VM"
        pipeline._retriever.retrieve.return_value = [mock_fused]

        # Mock EPHAISTOS - args complets
        pipeline._ephaistos.analyze_with_retry.return_value = EphaistosAnalysis(
            tool="vm_start",
            arguments={"vm_name": "preprod-09"},
            missing_args=[],
            confidence=0.95,
            reasoning="VM explicitement nommee",
            raw_response="{}"
        )

        # Mock LYRA confirmation
        pipeline._lyra.confirm_action.return_value = "Je vais demarrer preprod-09. Tu confirmes?"

        # Process
        result = pipeline.process("demarre preprod-09")

        assert result.query_type == QueryType.ACTION
        assert result.tool_call is not None
        assert "vm_start" in result.tool_call["name"]
        assert result.tool_call["arguments"]["vm_name"] == "preprod-09"
        assert len(result.pending_args) == 0
        assert not result.executed

    def test_action_with_missing_args(self, pipeline_with_mocks):
        """Test action avec arguments manquants."""
        pipeline = pipeline_with_mocks

        # Mock RAG results
        mock_fused = Mock()
        mock_fused.document = "vm_clone(source_vm: string, new_vm_name: string)"
        pipeline._retriever.retrieve.return_value = [mock_fused]

        # Mock EPHAISTOS - args manquants
        pipeline._ephaistos.analyze_with_retry.return_value = EphaistosAnalysis(
            tool="vm_clone",
            arguments={},
            missing_args=["source_vm", "new_vm_name"],
            confidence=0.85,
            reasoning="Intention claire mais VMs non specifiees",
            raw_response="{}"
        )

        # Mock LYRA question
        pipeline._lyra.ask_clarification.return_value = LyraResponse(
            text="D'accord pour cloner. C'est quelle VM source, et comment tu veux appeler la copie?",
            mentions_ephaistos=False,
            mentions_hestia=False
        )

        # Process
        result = pipeline.process("clone ma VM")

        assert result.query_type == QueryType.ACTION
        assert result.tool_call["name"] == "vm_clone"
        assert "source_vm" in result.pending_args
        assert "new_vm_name" in result.pending_args

        # Verifier que l'action est en attente
        pending = pipeline.get_pending_action()
        assert pending is not None
        assert pending.tool_name == "vm_clone"

    def test_action_no_tool_match(self, pipeline_with_mocks):
        """Test action sans outil correspondant."""
        pipeline = pipeline_with_mocks

        # Mock RAG results vides
        pipeline._retriever.retrieve.return_value = []

        # Process
        result = pipeline.process("fais quelque chose d'impossible")

        assert result.query_type == QueryType.ACTION
        assert result.error is not None
        assert "outil" in result.response.lower()


class TestMultiTurnConversation:
    """Tests du workflow multi-tour."""

    def test_clarification_then_complete(self, pipeline_with_mocks):
        """Test: clarification puis completion des arguments."""
        pipeline = pipeline_with_mocks

        # Setup initial - args manquants
        mock_fused = Mock()
        mock_fused.document = "vm_start(vm_name: string)"
        pipeline._retriever.retrieve.return_value = [mock_fused]

        pipeline._ephaistos.analyze_with_retry.return_value = EphaistosAnalysis(
            tool="vm_start",
            arguments={},
            missing_args=["vm_name"],
            confidence=0.85,
            reasoning="VM non specifiee",
            raw_response="{}"
        )

        pipeline._lyra.ask_clarification.return_value = LyraResponse(
            text="Quelle VM tu veux demarrer?",
            mentions_ephaistos=False,
            mentions_hestia=False
        )

        # Premier tour - clarification
        result1 = pipeline.process("demarre une VM")
        assert "vm_name" in result1.pending_args

        # Mock pour le deuxieme tour - args completes
        pipeline._ephaistos.extract_missing_args.return_value = EphaistosAnalysis(
            tool="vm_start",
            arguments={"vm_name": "preprod-09"},
            missing_args=[],
            confidence=0.95,
            reasoning="VM fournie",
            raw_response="{}"
        )

        pipeline._lyra.confirm_action.return_value = "Je demarre preprod-09?"

        # Deuxieme tour - completion
        result2 = pipeline.process("preprod-09")

        assert result2.tool_call is not None
        assert result2.tool_call["arguments"]["vm_name"] == "preprod-09"
        assert len(result2.pending_args) == 0

        # Verifier que l'action en attente est clear
        assert pipeline.get_pending_action() is None

    def test_multi_step_clarification(self, pipeline_with_mocks):
        """Test: clarification en plusieurs etapes."""
        pipeline = pipeline_with_mocks

        # Setup initial - 2 args manquants
        mock_fused = Mock()
        mock_fused.document = "vm_clone(source_vm: string, new_vm_name: string)"
        pipeline._retriever.retrieve.return_value = [mock_fused]

        pipeline._ephaistos.analyze_with_retry.return_value = EphaistosAnalysis(
            tool="vm_clone",
            arguments={},
            missing_args=["source_vm", "new_vm_name"],
            confidence=0.85,
            reasoning="VMs non specifiees",
            raw_response="{}"
        )

        pipeline._lyra.ask_clarification.return_value = LyraResponse(
            text="C'est quelle VM source?",
            mentions_ephaistos=False,
            mentions_hestia=False
        )

        # Tour 1 - demande initiale
        result1 = pipeline.process("clone ma VM")
        assert len(result1.pending_args) == 2

        # Tour 2 - donne source_vm, manque new_vm_name
        pipeline._ephaistos.extract_missing_args.return_value = EphaistosAnalysis(
            tool="vm_clone",
            arguments={"source_vm": "preprod-09"},
            missing_args=["new_vm_name"],
            confidence=0.90,
            reasoning="Source fournie, nom manquant",
            raw_response="{}"
        )

        result2 = pipeline.process("preprod-09")
        assert "new_vm_name" in result2.pending_args
        assert "source_vm" not in result2.pending_args

        # Tour 3 - donne new_vm_name
        pipeline._ephaistos.extract_missing_args.return_value = EphaistosAnalysis(
            tool="vm_clone",
            arguments={"source_vm": "preprod-09", "new_vm_name": "test-clone"},
            missing_args=[],
            confidence=0.95,
            reasoning="Tous args fournis",
            raw_response="{}"
        )

        pipeline._lyra.confirm_action.return_value = "Je clone preprod-09 vers test-clone?"

        result3 = pipeline.process("test-clone")
        # Tour 3 declenche la question COW (linked non specifie)
        assert "_linked_choice" in result3.pending_args or len(result3.pending_args) == 0,             f"Tour 3: attendu COW question ou pret a executer, got: {result3.response[:100]}"

        if "_linked_choice" in result3.pending_args:
            # Tour 4: repondre au choix COW
            result4 = pipeline.process("1")  # copie complete
            assert len(result4.pending_args) == 0
            assert result4.tool_call is not None
            assert result4.tool_call["arguments"]["source_vm"] == "preprod-09"
            assert result4.tool_call["arguments"]["new_vm_name"] == "test-clone"
        else:
            # Pas de question COW (linked deja dans args) - verifier le tool_call
            assert result3.tool_call["arguments"]["source_vm"] == "preprod-09"
            assert result3.tool_call["arguments"]["new_vm_name"] == "test-clone"


class TestKnowledgeWorkflow:
    """Tests du workflow knowledge."""

    def test_knowledge_with_context(self, pipeline_with_mocks):
        """Test question avec contexte RAG."""
        pipeline = pipeline_with_mocks

        # Configurer detect_query_type pour retourner KNOWLEDGE
        pipeline._retriever.detect_query_type.return_value = QueryType.KNOWLEDGE

        # Mock RAG results
        mock_fused = Mock()
        mock_fused.document = "vm_start demarre une machine virtuelle KVM"
        pipeline._retriever.retrieve.return_value = [mock_fused]

        # Mock LYRA response
        pipeline._lyra.answer_knowledge.return_value = LyraResponse(
            text="vm_start permet de demarrer une machine virtuelle KVM.",
            mentions_ephaistos=True,
            mentions_hestia=False
        )

        # Process
        result = pipeline.process("c'est quoi vm_start")

        assert result.query_type == QueryType.KNOWLEDGE
        assert "vm_start" in result.response.lower() or "demarrer" in result.response.lower()
        assert result.tool_call is None

    def test_knowledge_no_context(self, pipeline_with_mocks):
        """Test question sans contexte RAG."""
        pipeline = pipeline_with_mocks

        # Configurer detect_query_type pour retourner KNOWLEDGE
        pipeline._retriever.detect_query_type.return_value = QueryType.KNOWLEDGE

        # Mock RAG vide
        pipeline._retriever.retrieve.return_value = []

        # Mock LYRA response
        pipeline._lyra.answer_knowledge.return_value = LyraResponse(
            text="Je n'ai pas d'information precise sur ce sujet.",
            mentions_ephaistos=False,
            mentions_hestia=False
        )

        # Process
        result = pipeline.process("comment fonctionne xyz")

        assert result.query_type == QueryType.KNOWLEDGE


class TestExecution:
    """Tests de l'execution via HESTIA."""

    def test_execute_success(self, pipeline_with_mocks):
        """Test execution reussie."""
        pipeline = pipeline_with_mocks

        # Mock HESTIA
        pipeline._hestia.execute.return_value = ExecutionResult(
            success=True,
            content='{"status": "running", "ip": "192.168.122.146"}',
            duration_ms=1500.0
        )

        # Mock LYRA format
        pipeline._lyra.format_result.return_value = LyraResponse(
            text="C'est fait, preprod-09 est demarree. Son IP est 192.168.122.146.",
            mentions_ephaistos=False,
            mentions_hestia=True
        )

        # Execute
        result = pipeline.execute_action(
            tool_name="vm_start",
            arguments={"vm_name": "preprod-09"},
            user_query="demarre preprod-09"
        )

        assert result.executed
        assert result.execution_result.success
        assert "192.168.122.146" in result.response or "demarree" in result.response.lower()

    def test_execute_failure(self, pipeline_with_mocks):
        """Test execution echouee."""
        pipeline = pipeline_with_mocks

        # Mock HESTIA failure
        pipeline._hestia.execute.return_value = ExecutionResult(
            success=False,
            content="",
            error="VM not found: nonexistent-vm",
            duration_ms=500.0
        )

        # Mock LYRA error format
        pipeline._lyra.format_error.return_value = LyraResponse(
            text="Il y a eu un probleme: la VM n'existe pas.",
            mentions_ephaistos=False,
            mentions_hestia=True
        )

        # Execute
        result = pipeline.execute_action(
            tool_name="vm_start",
            arguments={"vm_name": "nonexistent-vm"},
            user_query="demarre nonexistent-vm"
        )

        assert result.executed
        assert not result.execution_result.success
        assert result.error is not None


class TestDangerousActions:
    """Tests des actions dangereuses."""

    def test_is_dangerous_vm_destroy(self, pipeline_with_mocks):
        """vm_destroy est dangereuse."""
        pipeline = pipeline_with_mocks
        pipeline._hestia.is_dangerous_tool.return_value = True

        assert pipeline.is_dangerous_action("vm_destroy")

    def test_is_dangerous_backup_restore(self, pipeline_with_mocks):
        """backup_restore est dangereuse."""
        pipeline = pipeline_with_mocks
        pipeline._hestia.is_dangerous_tool.return_value = True

        assert pipeline.is_dangerous_action("backup_restore")

    def test_is_not_dangerous_vm_start(self, pipeline_with_mocks):
        """vm_start n'est pas dangereuse."""
        pipeline = pipeline_with_mocks
        pipeline._hestia.is_dangerous_tool.return_value = False

        assert not pipeline.is_dangerous_action("vm_start")


class TestSessionManagement:
    """Tests de la gestion de session."""

    def test_session_history(self, pipeline_with_mocks):
        """Test historique de session."""
        pipeline = pipeline_with_mocks

        # Mock pour deux tours
        mock_fused = Mock()
        mock_fused.document = "vm_status(vm_name?: string)"
        pipeline._retriever.retrieve.return_value = [mock_fused]

        pipeline._ephaistos.analyze_with_retry.return_value = EphaistosAnalysis(
            tool="vm_status",
            arguments={},
            missing_args=[],
            confidence=0.90,
            reasoning="Lister toutes les VMs",
            raw_response="{}"
        )

        pipeline._lyra.confirm_action.return_value = "Je liste les VMs?"

        # Deux tours
        pipeline.process("liste les VMs")
        pipeline.process("status")

        history = pipeline.get_session_history()
        assert len(history) == 2

    def test_clear_session(self, pipeline_with_mocks):
        """Test effacement de session."""
        pipeline = pipeline_with_mocks

        # Setup avec pending action
        pipeline._session.set_pending_action(
            tool_name="vm_clone",
            known_args={},
            missing_args=["source_vm"],
            clarification_question="Quelle VM?"
        )

        assert pipeline.get_pending_action() is not None

        # Clear
        pipeline.clear_session()

        assert pipeline.get_pending_action() is None
        assert len(pipeline.get_session_history()) == 0


class TestMetrics:
    """Tests des metriques."""

    def test_get_metrics(self, pipeline_with_mocks):
        """Test recuperation des metriques."""
        pipeline = pipeline_with_mocks

        # Mock stats
        pipeline._model_manager.get_stats.return_value = {
            "ephaistos_calls": 5,
            "lyra_calls": 3,
            "total_calls": 8
        }

        pipeline._hestia.get_metrics.return_value = {
            "total_executions": 10,
            "success_rate": 0.9
        }

        metrics = pipeline.get_metrics()

        assert "models" in metrics
        assert "hestia" in metrics
        assert "session" in metrics
        assert metrics["models"]["total_calls"] == 8


class TestProcessWithContext:
    """Tests de process_with_context."""

    def test_restore_pending_action(self, pipeline_with_mocks):
        """Test restauration d'action en attente."""
        pipeline = pipeline_with_mocks

        # Mock pour le traitement
        pipeline._ephaistos.extract_missing_args.return_value = EphaistosAnalysis(
            tool="vm_start",
            arguments={"vm_name": "test-vm"},
            missing_args=[],
            confidence=0.95,
            reasoning="Args complets",
            raw_response="{}"
        )

        pipeline._lyra.confirm_action.return_value = "Je demarre test-vm?"

        # Process avec contexte
        result = pipeline.process_with_context(
            query="test-vm",
            pending_action={
                "tool_name": "vm_start",
                "known_args": {},
                "missing_args": ["vm_name"],
                "question": "Quelle VM?"
            }
        )

        assert result.tool_call is not None
        assert result.tool_call["name"] == "vm_start"