389 lines
14 KiB
Python
389 lines
14 KiB
Python
"""
|
|
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)
|
|
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
|