test(e2e): add comprehensive Senior QA E2E text analysis and LLM fallback funnel suite

This commit is contained in:
2026-08-21 11:06:57 -03:00
parent bae144055e
commit a874b98dac
17 changed files with 2260 additions and 815 deletions
+388
View File
@@ -0,0 +1,388 @@
"""
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.adapters.llm import LLMFallbackAdapter
from src.classifier import InherenceClassifier
from src.models import (
ClassificationResult,
DecisionCategory,
ECPSnapshot,
RelatedEntity,
)
CLASSIFY_CLI = Path(__file__).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)
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