feat(runtime): implement single-article consolidation runtime and modularize codebase
This commit is contained in:
@@ -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.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
|
||||
Reference in New Issue
Block a user