""" Suíte de Testes para o Adaptador de Fallback para LLM (Tier 3) do Classificador de Inerência. Cobre cenários unitários, de integração de pipeline, de parsing estruturado, de desambiguação de casos limiares e de degradação graciosa em falhas de API conforme o requisito FR-004. """ from __future__ import annotations import json import subprocess import sys from pathlib import Path from src.adapters.llm import LLMFallbackAdapter from src.classifier import InherenceClassifier from src.models import ( ClassificationResult, DecisionCategory, ECPSnapshot, ) SCRIPT_PATH = Path(__file__).parent.parent / "classify.py" # ============================================================================== # 1. Testes Unitários do LLMFallbackAdapter # ============================================================================== def test_llm_adapter_availability_detection(): """Valida detecção de disponibilidade por chave de API ou provider customizado.""" # Sem chave e sem provider adapter_empty = LLMFallbackAdapter(api_key="") assert adapter_empty.is_available() is False # Com chave de API adapter_with_key = LLMFallbackAdapter(api_key="sk-test-key-12345") assert adapter_with_key.is_available() is True # Com provider function adapter_with_fn = LLMFallbackAdapter( api_key="", provider_fn=lambda p: '{"decision": "DIRECT_INHERENT"}' ) assert adapter_with_fn.is_available() is True def test_llm_adapter_build_prompt_structure(): """Valida a montagem do prompt de desambiguação com metadados do ECP e documento.""" adapter = LLMFallbackAdapter(api_key="test") ecp = ECPSnapshot( target_entity_id="ecp_river", target_name="River Plate", aliases=["Club Atlético River Plate", "CARP"], domain="Futebol", anchors=["Monumental", "Libertadores"], ) initial_res = ClassificationResult( decision=DecisionCategory.TANGENTIAL, is_inherent=False, confidence=0.40, detected_language="es", matched_anchors=["River"], negative_matches=[], graph_matches=[], evidence=["River"], rationale="Passing mention.", warnings=[], ) prompt = adapter.build_prompt(ecp, "# Título do Artigo\n\nConteúdo sobre o jogo.", initial_res) assert "River Plate" in prompt assert "Futebol" in prompt assert "TANGENTIAL" in prompt assert "Título do Artigo" in prompt def test_llm_adapter_parsing_valid_json_response(): """Valida o parsing e instanciação correta do ClassificationResult a partir da resposta do LLM.""" adapter = LLMFallbackAdapter( provider_fn=lambda p: json.dumps( { "decision": "DIRECT_INHERENT", "confidence": 0.95, "rationale": "Artigo detalha o desempenho da equipe no torneio.", } ) ) ecp = ECPSnapshot( target_entity_id="ecp_test", target_name="Test Entity", aliases=["Test"], domain="Tech", anchors=["cloud"], ) initial = ClassificationResult( decision=DecisionCategory.TANGENTIAL, is_inherent=False, confidence=0.40, detected_language="pt", matched_anchors=["Test"], negative_matches=[], graph_matches=[], evidence=["Test"], rationale="Weak match.", warnings=["Low contextual density."], ) refined = adapter.disambiguate(ecp, "Document content...", initial) assert refined is not None assert refined.decision == DecisionCategory.DIRECT_INHERENT assert refined.is_inherent is True assert refined.confidence == 0.95 assert "[Tier 3 LLM]" in refined.rationale assert "[Tier 3 LLM Override applied]" in refined.warnings def test_llm_adapter_parsing_json_wrapped_in_markdown_codeblock(): """Valida extração de JSON quando a resposta do LLM vem formatada em bloco markdown ```json ... ```.""" raw_md_json = '```json\n{\n "decision": "CONTEXTUAL_INHERENT",\n "confidence": 0.88,\n "rationale": "Conexão contextual forte através da subsidiária."\n}\n```' adapter = LLMFallbackAdapter(provider_fn=lambda p: raw_md_json) ecp = ECPSnapshot( target_entity_id="ecp_test", target_name="Test Entity", aliases=["Test"], domain="Tech", anchors=["cloud"], ) initial = ClassificationResult( decision=DecisionCategory.TANGENTIAL, is_inherent=False, confidence=0.40, detected_language="pt", matched_anchors=[], negative_matches=[], graph_matches=[], evidence=[], rationale="Weak match.", warnings=[], ) refined = adapter.disambiguate(ecp, "Content...", initial) assert refined is not None assert refined.decision == DecisionCategory.CONTEXTUAL_INHERENT assert refined.is_inherent is True assert refined.confidence == 0.88 def test_llm_adapter_handling_invalid_and_corrupt_responses(): """Valida que respostas corrompidas ou JSONs sem campos obrigatórios retornem None com segurança.""" def make_bad_provider(resp_str: str): def _prov(prompt: str) -> str: return resp_str return _prov for bad_response in [ "Desculpe, não consegui avaliar o texto.", "{json_invalido_sem_fechamento", json.dumps({"campo_desconhecido": "valor"}), json.dumps({"decision": "DECISAO_INEXISTENTE"}), ]: adapter = LLMFallbackAdapter(provider_fn=make_bad_provider(bad_response)) ecp = ECPSnapshot( target_entity_id="ecp_test", target_name="Test Entity", aliases=["Test"], domain="Tech", anchors=["cloud"], ) initial = ClassificationResult( decision=DecisionCategory.TANGENTIAL, is_inherent=False, confidence=0.40, detected_language="pt", matched_anchors=[], negative_matches=[], graph_matches=[], evidence=[], rationale="Initial.", warnings=[], ) assert adapter.disambiguate(ecp, "Content...", initial) is None # ============================================================================== # 2. Testes de Integração de Pipeline (InherenceClassifier com Tier 3) # ============================================================================== def test_classifier_triggers_tier3_on_ambiguous_tangential_case(): """ Garante que o classificador dispare o Tier 3 LLM para casos ambíguos (TANGENTIAL) e adote o refinamento retornado. """ mock_adapter = LLMFallbackAdapter( provider_fn=lambda prompt: json.dumps( { "decision": "DIRECT_INHERENT", "confidence": 0.92, "rationale": "Análise profunda revelou que o texto é focado na entidade alvo.", } ) ) classifier = InherenceClassifier(enable_llm=True, llm_adapter=mock_adapter) ecp = ECPSnapshot( target_entity_id="ecp_empresa", target_name="EmpresaAlfa", aliases=["EmpresaAlfa"], domain="Tecnologia", anchors=["software", "computação em nuvem"], ) # Texto com menção única sem âncoras temáticas (Tier 1 produziria TANGENTIAL) ambiguous_content = "A EmpresaAlfa esteve presente no evento de encerramento anual da cidade." result = classifier.classify(ecp, ambiguous_content) # Como enable_llm=True e o caso era TANGENTIAL, o Tier 3 substitui a decisão assert result.decision == DecisionCategory.DIRECT_INHERENT assert result.is_inherent is True assert result.confidence == 0.92 assert "[Tier 3 LLM]" in result.rationale def test_classifier_skips_tier3_on_clear_direct_inherent_case(): """ Garante que casos claros (alta confiança e alta densidade de âncoras) NÃO chamem o LLM, economizando chamadas desnecessárias conforme FR-004. """ call_tracker = {"called": False} def tracking_provider(prompt: str) -> str: call_tracker["called"] = True return json.dumps({"decision": "DIRECT_INHERENT", "confidence": 0.99}) mock_adapter = LLMFallbackAdapter(provider_fn=tracking_provider) classifier = InherenceClassifier(enable_llm=True, llm_adapter=mock_adapter) ecp = ECPSnapshot( target_entity_id="ecp_empresa", target_name="EmpresaAlfa", aliases=["EmpresaAlfa"], domain="Tecnologia", anchors=["software", "computação em nuvem", "inteligência artificial"], ) # Caso claro com alta densidade de âncoras clear_content = "A EmpresaAlfa desenvolveu uma nova plataforma de software baseada em computação em nuvem e inteligência artificial." result = classifier.classify(ecp, clear_content) assert result.decision == DecisionCategory.DIRECT_INHERENT assert result.confidence >= 0.85 # O LLM NÃO deve ter sido chamado assert call_tracker["called"] is False def test_classifier_graceful_degradation_when_llm_raises_exception(): """ Garante que se o LLM falhar por erro de rede ou timeout, o classificador mantenha o resultado do Tier 1 com degradação graciosa e registre o aviso em warnings. """ def failing_provider(prompt: str) -> str: raise ConnectionError("Timeout ao conectar com a API do modelo de linguagem.") mock_adapter = LLMFallbackAdapter(provider_fn=failing_provider) classifier = InherenceClassifier(enable_llm=True, llm_adapter=mock_adapter) ecp = ECPSnapshot( target_entity_id="ecp_empresa", target_name="EmpresaAlfa", aliases=["EmpresaAlfa"], domain="Tecnologia", anchors=["software"], ) ambiguous_content = "A EmpresaAlfa participou da conferência." result = classifier.classify(ecp, ambiguous_content) # Retém a decisão original do Tier 1 assert result.decision == DecisionCategory.TANGENTIAL assert result.is_inherent is False # Contém aviso sobre a falha do LLM sem quebrar a execução assert any("LLM fallback failed" in w for w in result.warnings) # ============================================================================== # 3. Teste de Integração CLI com a Flag --enable-llm # ============================================================================== def test_cli_execution_with_enable_llm_flag(tmp_path): """Garante que a CLI classify.py aceite e processe a flag --enable-llm sem erros.""" ecp_file = tmp_path / "test_ecp.json" ecp_file.write_text( json.dumps( { "target_entity_id": "ecp_test", "target_name": "TestCorp", "aliases": ["TestCorp"], "domain": "Tech", "anchors": ["software", "cloud"], } ), encoding="utf-8", ) content_file = tmp_path / "test_doc.md" content_file.write_text("# TestCorp\n\nTestCorp builds cloud software.", encoding="utf-8") output_file = tmp_path / "out.json" res = subprocess.run( [ sys.executable, str(SCRIPT_PATH), "--ecp", str(ecp_file), "--content", str(content_file), "--output", str(output_file), "--enable-llm", ], capture_output=True, text=True, ) assert res.returncode == 0, f"Erro na CLI: {res.stderr}" assert output_file.exists() data = json.loads(output_file.read_text(encoding="utf-8")) assert data["decision"] == "DIRECT_INHERENT" assert data["is_inherent"] is True