""" Suíte de Testes E2E e de Integração Completa para Análise de Texto e Classificação de Inerência (QA Sênior). Valida todo o funil de análise de texto: 1. Sucesso no NLP Determinístico (Tier 1 DIRECT_INHERENT com bypass de LLM). 2. Insucesso / Rejeição no NLP (Tier 1 NOT_RELATED por âncoras negativas e homônimos). 3. Ambiguidade / Limiar detectada no NLP e resolvida com sucesso no LLM (Tier 3 Upgrade). 4. Ambiguidade confirmada pelo LLM como TANGENTIAL (Tier 3 Confirmation). 5. Insucesso / Falha de API de LLM com degradação graciosa para Tier 1. 6. Cobertura Multilíngue E2E nos 6 idiomas (PT, EN, ES, DE, IT, FR). 7. Execução E2E via CLI subprocess com contratos de entrada, saída e flags. 8. Chamada real ao vivo a provedores de LLM (OpenAI/Gemini) quando credenciais estiverem disponíveis. """ from __future__ import annotations import json import subprocess import sys from pathlib import Path import pytest from src.tools.adapters.llm import LLMFallbackAdapter from src.tools.classifier import InherenceClassifier from src.tools.models import ( ClassificationResult, DecisionCategory, ECPSnapshot, RelatedEntity, ) CLASSIFY_CLI = Path(__file__).parent.parent.parent / "classify.py" # ============================================================================== # Fixtures e Helpers para o Funil de Teste E2E # ============================================================================== @pytest.fixture def ecp_river_plate() -> ECPSnapshot: """Fixture ECP oficial para o Club Atlético River Plate.""" return ECPSnapshot( target_entity_id="ecp_river_plate", target_name="Club Atlético River Plate", aliases=[ "Club Atlético River Plate", "River Plate", "River", "El Millonario", "La Banda", "CARP", ], domain="Futebol / Esportes", anchors=[ "fútbol", "Copa Libertadores", "Libertadores", "Copa Sudamericana", "Sudamericana", "Monumental", "Estadio Monumental", "Eduardo Coudet", "Coudet", "Nicolás Otamendi", "Otamendi", "Rafael Santos Borré", ], negative_anchors=[ "River Plate de Montevideo", "River Plate de Asunción", "River de Piauí", "Rio da Prata", "Bacia do Rio da Prata", ], related_entities=[ RelatedEntity( entity_id="estadio_monumental", name="Estadio Mâs Monumental", relation_type="HOME_VENUE_OF", weight=0.95, aliases=["Monumental", "El Monumental"], ), RelatedEntity( entity_id="copa_sudamericana", name="Copa Sudamericana", relation_type="COMPETES_IN", weight=0.85, aliases=["Sudamericana"], ), ], ) # ============================================================================== # 1. Funil de Sucesso NLP Determinístico (Tier 1) # ============================================================================== def test_funnel_nlp_deterministic_success(ecp_river_plate: ECPSnapshot): """ Cenário 1: Artigo com alta densidade de âncoras do River Plate. Oráculo: Decisão DIRECT_INHERENT, confiança alta (>=0.95), LLM não é chamado. """ content = """ # River Plate vence com autoridade na Copa Sudamericana Em noite histórica no Estadio Monumental, o River dominou a partida sob o comando de Eduardo Coudet. Otamendi e Borré marcaram os gols que garantiram a classificação na Sudamericana. """ llm_called = {"status": False} def mock_llm(prompt: str) -> str: llm_called["status"] = True return json.dumps({"decision": "DIRECT_INHERENT", "confidence": 0.99}) adapter = LLMFallbackAdapter(provider_fn=mock_llm) classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter) result = classifier.classify(ecp_river_plate, content) assert result.decision == DecisionCategory.DIRECT_INHERENT assert result.is_inherent is True assert result.confidence >= 0.95 assert "River Plate" in result.matched_anchors or "River" in result.matched_anchors assert len(result.graph_matches) >= 1 # Otimização de custo: LLM NÃO deve ser chamado em casos determinísticos claros assert llm_called["status"] is False # ============================================================================== # 2. Funil de Insucesso / Rejeição NLP (Tier 1 Negativas e Homônimos) # ============================================================================== def test_funnel_nlp_deterministic_rejection_homonym(ecp_river_plate: ECPSnapshot): """ Cenário 2: Artigo sobre a Bacia do Rio da Prata ou clube homônimo do Uruguai. Oráculo: Decisão NOT_RELATED, is_inherent=False, âncoras negativas detectadas. """ content = """ # Expedição ambiental navega pela Bacia do Rio da Prata Pesquisadores mapearam a biodiversidade fluvial e os sedimentos do Rio da Prata durante o verão. """ classifier = InherenceClassifier(enable_llm=False) result = classifier.classify(ecp_river_plate, content) assert result.decision == DecisionCategory.NOT_RELATED assert result.is_inherent is False assert any("Rio da Prata" in neg for neg in result.negative_matches) assert "Negative anchor" in result.rationale # ============================================================================== # 3. Funil de Ambiguidade NLP -> Resolução com Sucesso no LLM (Tier 3) # ============================================================================== def test_funnel_nlp_ambiguity_resolved_by_llm_upgrade(ecp_river_plate: ECPSnapshot): """ Cenário 3: Menção isolada do clube ('River') em contexto com poucas âncoras explícitas. Tier 1 preliminar: TANGENTIAL (baixa densidade). LLM Fallback: Analisa o contexto profundo e eleva para DIRECT_INHERENT. """ ambiguous_content = """ # Bastidores do mercado sul-americano A diretoria do River finalizou os últimos detalhes contratuais para a renovação de jovens promessas. """ mock_llm_response = json.dumps( { "analysis_summary": "O artigo trata da gestão de elenco e renovações contratuais do clube River Plate.", "decision": "DIRECT_INHERENT", "confidence": 0.94, "rationale": "A análise contextual profunda comprova que a matéria é focada na administração do River Plate.", } ) adapter = LLMFallbackAdapter(provider_fn=lambda prompt: mock_llm_response) classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter) result = classifier.classify(ecp_river_plate, ambiguous_content) assert result.decision == DecisionCategory.DIRECT_INHERENT assert result.is_inherent is True assert result.confidence == 0.94 assert "[Tier 3 LLM]" in result.rationale assert "[Tier 3 LLM Override applied]" in result.warnings # ============================================================================== # 4. Funil de Ambiguidade NLP -> Confirmação de Tangencial no LLM # ============================================================================== def test_funnel_nlp_ambiguity_confirmed_tangential_by_llm(ecp_river_plate: ECPSnapshot): """ Cenário 4: Menção metafórica ou turística a um local próximo. Tier 1 preliminar: TANGENTIAL. LLM Fallback: Confirma que é meramente periférico/ilustrativo. """ tangential_content = """ # Melhores restaurantes do bairro de Núñez em Buenos Aires Ao visitar a capital portenha, próximo de onde fica o River, você encontra excelentes opções gastronômicas. """ mock_llm_response = json.dumps( { "analysis_summary": "Guia gastronômico sobre o bairro de Núñez com citação geográfica casual ao clube.", "decision": "TANGENTIAL", "confidence": 0.96, "rationale": "A entidade é usada apenas como ponto de referência geográfica em um artigo sobre restaurantes.", } ) adapter = LLMFallbackAdapter(provider_fn=lambda prompt: mock_llm_response) classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter) result = classifier.classify(ecp_river_plate, tangential_content) assert result.decision == DecisionCategory.TANGENTIAL assert result.is_inherent is False assert result.confidence == 0.96 assert "[Tier 3 LLM]" in result.rationale # ============================================================================== # 5. Funil de Insucesso / Degradação Graciosa em Falha do LLM # ============================================================================== def test_funnel_llm_failure_graceful_degradation(ecp_river_plate: ECPSnapshot): """ Cenário 5: LLM configurado, caso ambíguo, mas a API externa sofre timeout/500. Oráculo: Mantém o resultado do Tier 1 determinístico com warning detalhado e sem quebrar. """ def broken_llm(prompt: str) -> str: raise TimeoutError("Conexão com serviço de LLM excedeu 30 segundos.") adapter = LLMFallbackAdapter(provider_fn=broken_llm) classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter) content = "O River esteve presente no evento de inauguração da praça." result = classifier.classify(ecp_river_plate, content) # Mantém o Tier 1 determinístico assert result.decision == DecisionCategory.TANGENTIAL assert result.is_inherent is False assert any("LLM fallback failed" in w for w in result.warnings) # ============================================================================== # 6. Cobertura Multilíngue nos 6 Idiomas (PT, EN, ES, DE, IT, FR) # ============================================================================== @pytest.mark.parametrize( "lang_code,content,expected_lang", [ ("pt", "# Petrobras anuncia perfuração no pré-sal com tecnologia nacional.", "pt"), ("en", "# Apple unveils new generative AI features for upcoming devices.", "en"), ( "es", "# River Plate prepara su viaje a Bogotá para disputar el torneo continental.", "es", ), ( "de", "# Volkswagen investiert Milliarden in neue Batterie-Fabriken in Deutschland.", "de", ), ("it", "# Ferrari conquista la pole position nel Gran Premio di Monza.", "it"), ( "fr", "# L'entreprise TotalEnergies accélère ses investissements solaires en France.", "fr", ), ], ) def test_funnel_multilingual_language_detection( lang_code: str, content: str, expected_lang: str, ecp_river_plate: ECPSnapshot ): """Garante a identificação precisa de idioma e integridade nos 6 idiomas suportados.""" classifier = InherenceClassifier(enable_llm=False) result = classifier.classify(ecp_river_plate, content) assert result.detected_language == expected_lang # ============================================================================== # 7. Execução E2E via CLI Subprocess # ============================================================================== def test_funnel_cli_subprocess_end_to_end(tmp_path: Path): """Valida o contrato CLI completo classify.py com saída em arquivo JSON e flags ativas.""" ecp_path = tmp_path / "ecp.json" ecp_path.write_text( json.dumps( { "target_entity_id": "ecp_test_e2e", "target_name": "Clube Teste", "aliases": ["Clube Teste", "Clube"], "domain": "Esportes", "anchors": ["campeonato", "vitória", "torneio"], } ), encoding="utf-8", ) doc_path = tmp_path / "artigo.md" doc_path.write_text( "# Clube Teste comemora vitória histórica no campeonato\n\nEquipe foi campeã do torneio.", encoding="utf-8", ) out_path = tmp_path / "resultado.json" res = subprocess.run( [ sys.executable, str(CLASSIFY_CLI), "--ecp", str(ecp_path), "--content", str(doc_path), "--output", str(out_path), "--enable-llm", ], capture_output=True, text=True, ) assert res.returncode == 0, f"Erro na CLI: {res.stderr}" assert out_path.exists() payload = json.loads(out_path.read_text(encoding="utf-8")) assert payload["decision"] == "DIRECT_INHERENT" assert payload["is_inherent"] is True assert payload["confidence"] >= 0.85 assert isinstance(payload["matched_anchors"], list) assert isinstance(payload["evidence"], list) # ============================================================================== # 8. Teste Live Opt-In com API Real (OpenAI / Gemini) se .env Estiver Presente # ============================================================================== def test_funnel_live_api_execution_if_configured(): """ Executa chamada ao vivo contra OpenAI ou Gemini caso OPENAI_API_KEY ou GEMINI_API_KEY esteja configurada no ambiente ou no arquivo .env. """ adapter = LLMFallbackAdapter() if not (adapter.openai_api_key or adapter.gemini_api_key): pytest.skip( "Chaves de API reais (OPENAI_API_KEY ou GEMINI_API_KEY) não configuradas no .env" ) ecp = ECPSnapshot( target_entity_id="ecp_live_test", target_name="Club Atlético River Plate", aliases=["River Plate", "River"], 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 detected by Tier 1.", warnings=[], ) # Texto de teste para a API ao vivo content = "O River Plate empatou em 1 a 1 em Bogotá com gols de Otamendi na Copa Sul-Americana." refined = adapter.disambiguate(ecp, content, initial_res) if refined is None and initial_res.warnings: pytest.skip(f"Live API unavailable or blocked: {initial_res.warnings}") assert refined is not None assert refined.decision in [ DecisionCategory.DIRECT_INHERENT, DecisionCategory.CONTEXTUAL_INHERENT, ] assert refined.is_inherent is True assert "[Tier 3 LLM]" in refined.rationale