test(qa): add exhaustive 38-scenario test suite covering happy, sad, borderline, LLM fallback, CLI contracts, and multilingual matrix
This commit is contained in:
@@ -0,0 +1,806 @@
|
||||
"""
|
||||
Suíte de Testes Exaustiva para o Classificador de Inerência (classify.py e src/).
|
||||
|
||||
Cobre 100% dos caminhos felizes, infelizes, limiares, de ambiguidade,
|
||||
fallback de LLM (OpenAI e Gemini), resiliência de API, erros de contrato CLI
|
||||
e suporte aos 6 idiomas conforme a metodologia da skill-suite-tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from classify import main
|
||||
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 Universais
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ecp_tech_corp() -> ECPSnapshot:
|
||||
return ECPSnapshot(
|
||||
target_entity_id="ent_tech_corp",
|
||||
target_name="TechCorp Global",
|
||||
aliases=["TechCorp", "TechCorp Global", "TCG"],
|
||||
domain="Tecnologia e Cloud",
|
||||
anchors=[
|
||||
"cloud",
|
||||
"computação em nuvem",
|
||||
"software",
|
||||
"inteligência artificial",
|
||||
"datacenter",
|
||||
],
|
||||
negative_anchors=["TechCorp Calçados", "TechCorp Imóveis", "homônimo"],
|
||||
related_entities=[
|
||||
RelatedEntity(
|
||||
entity_id="ent_cloud_subsidiary",
|
||||
name="CloudPlatform Solutions",
|
||||
relation_type="SUBSIDIARY_OF",
|
||||
weight=0.90,
|
||||
aliases=["CloudPlatform"],
|
||||
scope="cloud_services",
|
||||
),
|
||||
RelatedEntity(
|
||||
entity_id="ent_ceo_tech",
|
||||
name="Alan Turing Silva",
|
||||
relation_type="CEO_OF",
|
||||
weight=0.80,
|
||||
aliases=["Alan Turing"],
|
||||
scope="executive",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 1. Casos Felizes (Happy Paths) - NLP Determinístico (Tier 1)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def test_happy_path_direct_inherent_with_canonical_and_anchors(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 1.1: Nome canônico + múltiplas âncoras temáticas -> DIRECT_INHERENT com alta confiança."""
|
||||
classifier = InherenceClassifier()
|
||||
content = (
|
||||
"# TechCorp Global anuncia novo datacenter de computação em nuvem\n\n"
|
||||
"A TechCorp Global investiu 500 milhões para expandir sua infraestrutura de software "
|
||||
"e inteligência artificial na América Latina."
|
||||
)
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.DIRECT_INHERENT
|
||||
assert res.is_inherent is True
|
||||
assert res.confidence >= 0.90
|
||||
assert "TechCorp Global" in res.matched_anchors or "TechCorp" in res.matched_anchors
|
||||
assert len(res.evidence) >= 1
|
||||
|
||||
|
||||
def test_happy_path_direct_inherent_via_alias_and_acronym(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 1.2: Apenas o alias / sigla 'TCG' é mencionado, com âncoras do domínio."""
|
||||
classifier = InherenceClassifier()
|
||||
content = (
|
||||
"# Inovação em Cloud\n\n"
|
||||
"A TCG lançou hoje uma nova plataforma de software baseada em computação em nuvem."
|
||||
)
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.DIRECT_INHERENT
|
||||
assert res.is_inherent is True
|
||||
assert res.confidence >= 0.85
|
||||
|
||||
|
||||
def test_happy_path_direct_inherent_by_repetition_without_heavy_anchors(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 1.3: O nome 'TechCorp' aparece 3 vezes no texto, satisfazendo a regra de menção múltipla."""
|
||||
classifier = InherenceClassifier()
|
||||
content = (
|
||||
"# Relatório Corporativo Trimestral\n\n"
|
||||
"A TechCorp divulgou seus resultados. A TechCorp superou as estimativas de analistas. "
|
||||
"O conselho da TechCorp aprovou dividendos extraordinários."
|
||||
)
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.DIRECT_INHERENT
|
||||
assert res.is_inherent is True
|
||||
assert res.confidence >= 0.85
|
||||
|
||||
|
||||
def test_happy_path_contextual_inherent_via_subsidiary_graph_entity(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 1.4: Menção da subsidiária 'CloudPlatform Solutions' com âncoras de cloud."""
|
||||
classifier = InherenceClassifier()
|
||||
content = (
|
||||
"# Expansão de Infraestrutura de Nuvem\n\n"
|
||||
"A CloudPlatform Solutions ativou novos servidores em seu datacenter de computação em nuvem."
|
||||
)
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.CONTEXTUAL_INHERENT
|
||||
assert res.is_inherent is True
|
||||
assert res.confidence >= 0.75
|
||||
assert len(res.graph_matches) >= 1
|
||||
assert res.graph_matches[0]["name"] == "CloudPlatform Solutions"
|
||||
|
||||
|
||||
def test_happy_path_contextual_inherent_via_executive_graph_entity(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 1.5: Menção ao CEO no grafo + âncoras de tecnologia."""
|
||||
classifier = InherenceClassifier()
|
||||
content = (
|
||||
"# Discurso na Conferência de Tecnologia\n\n"
|
||||
"O executivo Alan Turing Silva discursou sobre o futuro da inteligência artificial e software."
|
||||
)
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.CONTEXTUAL_INHERENT
|
||||
assert res.is_inherent is True
|
||||
assert any(g["name"] == "Alan Turing Silva" for g in res.graph_matches)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2. Casos Infelizes e Rejeições (Sad Paths) - NLP Determinístico (Tier 1)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def test_sad_path_not_related_completely_off_topic(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 2.1: Conteúdo totalmente desvinculado (culinária/jardinagem)."""
|
||||
classifier = InherenceClassifier()
|
||||
content = (
|
||||
"# Receita de Pão Caseiro Fácil\n\n"
|
||||
"Misture a farinha, o fermento biológico seco e a água morna. "
|
||||
"Deixe a massa descansar por 40 minutos em local aquecido."
|
||||
)
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.NOT_RELATED
|
||||
assert res.is_inherent is False
|
||||
assert res.confidence >= 0.90
|
||||
assert len(res.matched_anchors) == 0
|
||||
|
||||
|
||||
def test_sad_path_not_related_generic_domain_without_target_or_graph(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 2.2: Artigo cita muitas âncoras ('cloud', 'software'), mas NÃO cita a TechCorp nem o grafo."""
|
||||
classifier = InherenceClassifier()
|
||||
content = (
|
||||
"# O Mercado Global de Computação em Nuvem\n\n"
|
||||
"O setor de computação em nuvem, datacenter e inteligência artificial cresceu 25% este ano."
|
||||
)
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.NOT_RELATED
|
||||
assert res.is_inherent is False
|
||||
assert (
|
||||
"General domain topics mentioned, but target entity or related entities are absent."
|
||||
in res.rationale
|
||||
)
|
||||
|
||||
|
||||
def test_sad_path_not_related_negative_anchor_dominance(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 2.3: Homônimo 'TechCorp Calçados' dispara âncora negativa dominante."""
|
||||
classifier = InherenceClassifier()
|
||||
content = (
|
||||
"# Feira de Moda e Varejo\n\n"
|
||||
"A TechCorp Calçados apresentou sua nova linha de sandálias de couro para o verão."
|
||||
)
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.NOT_RELATED
|
||||
assert res.is_inherent is False
|
||||
assert "TechCorp Calçados" in res.negative_matches
|
||||
|
||||
|
||||
def test_sad_path_not_related_negative_anchor_ties_with_positive_anchor(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 2.4: 1 âncora negativa e 1 positiva -> prioridade de segurança rejeita para NOT_RELATED."""
|
||||
classifier = InherenceClassifier()
|
||||
content = "A TechCorp Calçados adotou um novo software interno de gestão."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.NOT_RELATED
|
||||
assert res.is_inherent is False
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 3. Casos Limiares e Ambiguidades (Borderline / Tangential)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def test_borderline_tangential_single_passing_mention(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 3.1: Menção única isolada sem âncoras temáticas -> TANGENTIAL com baixa confiança."""
|
||||
classifier = InherenceClassifier()
|
||||
content = "Estávamos caminhando pela avenida e vimos a placa da TechCorp ao longe na esquina."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.TANGENTIAL
|
||||
assert res.is_inherent is False
|
||||
assert res.confidence == 0.40
|
||||
assert any("Low contextual density" in w for w in res.warnings)
|
||||
|
||||
|
||||
def test_borderline_tangential_graph_entity_in_isolation(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 3.2: Entidade do grafo mencionada sem contexto de domínio -> TANGENTIAL."""
|
||||
classifier = InherenceClassifier()
|
||||
content = "Alan Turing Silva participou de uma corrida beneficente no parque no domingo."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.TANGENTIAL
|
||||
assert res.is_inherent is False
|
||||
assert res.confidence == 0.45
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 4. Suíte Abrangente de Fallback para LLM (Tier 3)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def test_llm_happy_path_upgrade_tangential_to_direct_inherent(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.1: Caso ambíguo elevado para DIRECT_INHERENT pelo LLM."""
|
||||
mock_resp = json.dumps(
|
||||
{
|
||||
"analysis_summary": "Artigo detalha o projeto estratégico secreto da TechCorp.",
|
||||
"decision": "DIRECT_INHERENT",
|
||||
"confidence": 0.95,
|
||||
"rationale": "Embora a redação use linguagem coloquial, o artigo foca inteiramente na estratégia da TechCorp.",
|
||||
}
|
||||
)
|
||||
adapter = LLMFallbackAdapter(provider_fn=lambda p: mock_resp)
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = "A diretoria da TechCorp finalizou as negociações confidenciais da rodada."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
|
||||
assert res.decision == DecisionCategory.DIRECT_INHERENT
|
||||
assert res.is_inherent is True
|
||||
assert res.confidence == 0.95
|
||||
assert "[Tier 3 LLM]" in res.rationale
|
||||
assert "[Tier 3 LLM Override applied]" in res.warnings
|
||||
|
||||
|
||||
def test_llm_happy_path_upgrade_tangential_to_contextual_inherent(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.2: Caso ambíguo elevado para CONTEXTUAL_INHERENT pelo LLM."""
|
||||
mock_resp = json.dumps(
|
||||
{
|
||||
"analysis_summary": "Matéria sobre fusão de fornecedores onde a TechCorp é impactada diretamente.",
|
||||
"decision": "CONTEXTUAL_INHERENT",
|
||||
"confidence": 0.88,
|
||||
"rationale": "A TechCorp é parte material do ecossistema afetado pela fusão anunciada.",
|
||||
}
|
||||
)
|
||||
adapter = LLMFallbackAdapter(provider_fn=lambda p: mock_resp)
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = "O consórcio fornecedor foi reestruturado e envolverá contratos com a TechCorp."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
|
||||
assert res.decision == DecisionCategory.CONTEXTUAL_INHERENT
|
||||
assert res.is_inherent is True
|
||||
assert res.confidence == 0.88
|
||||
|
||||
|
||||
def test_llm_happy_path_confirmation_of_tangential(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.3: LLM confirma categoricamente que a menção é periférica / irrelevante."""
|
||||
mock_resp = json.dumps(
|
||||
{
|
||||
"analysis_summary": "Crônica sobre trânsito urbano com citação lateral a um outdoor da TechCorp.",
|
||||
"decision": "TANGENTIAL",
|
||||
"confidence": 0.97,
|
||||
"rationale": "A empresa é apenas uma referência visual casual sem relação com a narrativa de trânsito.",
|
||||
}
|
||||
)
|
||||
adapter = LLMFallbackAdapter(provider_fn=lambda p: mock_resp)
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = "O tráfego estava parado bem em frente ao painel da TechCorp na autoestrada."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
|
||||
assert res.decision == DecisionCategory.TANGENTIAL
|
||||
assert res.is_inherent is False
|
||||
assert res.confidence == 0.97
|
||||
|
||||
|
||||
def test_llm_happy_path_rejection_to_not_related(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.4: LLM identifica homônimo não mapeado nas regras determinísticas e rebaixa para NOT_RELATED."""
|
||||
mock_resp = json.dumps(
|
||||
{
|
||||
"analysis_summary": "Artigo sobre uma banda de rock indie com nome idêntico.",
|
||||
"decision": "NOT_RELATED",
|
||||
"confidence": 0.99,
|
||||
"rationale": "O texto refere-se a um grupo musical e não à empresa de tecnologia.",
|
||||
}
|
||||
)
|
||||
adapter = LLMFallbackAdapter(provider_fn=lambda p: mock_resp)
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = "A banda TechCorp tocou seus novos acordes no festival de música independente."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
|
||||
assert res.decision == DecisionCategory.NOT_RELATED
|
||||
assert res.is_inherent is False
|
||||
assert res.confidence == 0.99
|
||||
|
||||
|
||||
def test_llm_sad_path_llm_disabled_by_default_never_invokes_adapter(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.5: Quando enable_llm=False (padrão), o LLM NUNCA é chamado mesmo em caso limiar."""
|
||||
called = {"status": False}
|
||||
|
||||
def tracking_fn(p: str) -> str:
|
||||
called["status"] = True
|
||||
return "{}"
|
||||
|
||||
adapter = LLMFallbackAdapter(provider_fn=tracking_fn)
|
||||
classifier = InherenceClassifier(enable_llm=False, llm_adapter=adapter)
|
||||
|
||||
content = "Menção isolada da TechCorp sem contexto algum."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
|
||||
assert res.decision == DecisionCategory.TANGENTIAL
|
||||
assert called["status"] is False
|
||||
|
||||
|
||||
def test_llm_sad_path_flag_enabled_without_api_key_or_provider(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.6: enable_llm=True mas sem chaves no ambiente -> degrada sem quebrar, retém Tier 1."""
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
adapter = LLMFallbackAdapter(api_key="", provider_fn=None)
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = "Menção isolada da TechCorp em relatório breve."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
|
||||
assert res.decision == DecisionCategory.TANGENTIAL
|
||||
assert res.is_inherent is False
|
||||
|
||||
|
||||
def test_llm_sad_path_network_timeout_graceful_degradation(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.7: API do LLM sofre TimeoutError -> retém Tier 1 e registra aviso em warnings."""
|
||||
|
||||
def timeout_fn(p: str) -> str:
|
||||
raise TimeoutError("Conexão com gateway do LLM excedeu tempo limite de 30s.")
|
||||
|
||||
adapter = LLMFallbackAdapter(provider_fn=timeout_fn)
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = "A TechCorp esteve presente no evento de premiação."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
|
||||
assert res.decision == DecisionCategory.TANGENTIAL
|
||||
assert any("LLM fallback failed" in w for w in res.warnings)
|
||||
|
||||
|
||||
def test_llm_sad_path_http_500_server_error_graceful_degradation(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.8: API do LLM retorna erro 500 / ConnectionError -> retém Tier 1 com aviso."""
|
||||
|
||||
def error_500_fn(p: str) -> str:
|
||||
raise ConnectionError("HTTP 500: Internal Server Error do provedor de IA.")
|
||||
|
||||
adapter = LLMFallbackAdapter(provider_fn=error_500_fn)
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = "Menção da TechCorp em comunicado à imprensa."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
|
||||
assert res.decision == DecisionCategory.TANGENTIAL
|
||||
assert any("LLM fallback failed" in w for w in res.warnings)
|
||||
|
||||
|
||||
def test_llm_sad_path_malformed_json_and_non_json_strings(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.9: LLM retorna texto livre ou JSON quebrado -> parser ignora com segurança."""
|
||||
|
||||
def make_bad_provider(resp_text: str):
|
||||
def _prov(prompt: str) -> str:
|
||||
return resp_text
|
||||
|
||||
return _prov
|
||||
|
||||
for bad_resp in [
|
||||
"Não tenho certeza sobre este documento.",
|
||||
"{json_quebrado_sem_aspas: true",
|
||||
"```json\n{invalido: 123}\n```",
|
||||
]:
|
||||
adapter = LLMFallbackAdapter(provider_fn=make_bad_provider(bad_resp))
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = "Menção isolada da TechCorp."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.TANGENTIAL
|
||||
|
||||
|
||||
def test_llm_sad_path_missing_decision_key_in_json(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.10: LLM retorna JSON válido mas sem o campo obrigatório 'decision'."""
|
||||
adapter = LLMFallbackAdapter(
|
||||
provider_fn=lambda p: json.dumps({"confidence": 0.90, "rationale": "Faltou a decisao"})
|
||||
)
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = "Menção isolada da TechCorp."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.TANGENTIAL
|
||||
|
||||
|
||||
def test_llm_sad_path_unknown_hallucinated_decision_enum(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.11: LLM alucina uma categoria inexistente (ex: 'SUPER_INHERENT')."""
|
||||
adapter = LLMFallbackAdapter(
|
||||
provider_fn=lambda p: json.dumps({"decision": "SUPER_INHERENT", "confidence": 0.99})
|
||||
)
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = "Menção isolada da TechCorp."
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
assert res.decision == DecisionCategory.TANGENTIAL
|
||||
|
||||
|
||||
def test_llm_resilience_confidence_clipping(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.12: LLM retorna confidence fora do intervalo [0.0, 1.0] -> clippa com segurança."""
|
||||
|
||||
def make_clipping_provider(c_val: float):
|
||||
def _prov(prompt: str) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"decision": "DIRECT_INHERENT",
|
||||
"confidence": c_val,
|
||||
"rationale": "Teste de clipping.",
|
||||
}
|
||||
)
|
||||
|
||||
return _prov
|
||||
|
||||
for raw_conf, expected_conf in [(1.5, 1.0), (-0.5, 0.0), (0.85432, 0.8543)]:
|
||||
adapter = LLMFallbackAdapter(provider_fn=make_clipping_provider(raw_conf))
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
res = classifier.classify(ecp_tech_corp, "Menção da TechCorp.")
|
||||
assert res.confidence == expected_conf
|
||||
|
||||
|
||||
def test_llm_optimization_clear_case_bypasses_llm(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 4.13: Caso claro de alta densidade NÃO chama LLM mesmo com enable_llm=True."""
|
||||
called = {"status": False}
|
||||
|
||||
def tracking_fn(p: str) -> str:
|
||||
called["status"] = True
|
||||
return json.dumps({"decision": "DIRECT_INHERENT"})
|
||||
|
||||
adapter = LLMFallbackAdapter(provider_fn=tracking_fn)
|
||||
classifier = InherenceClassifier(enable_llm=True, llm_adapter=adapter)
|
||||
|
||||
content = (
|
||||
"# TechCorp Global anuncia nova inteligência artificial para computação em nuvem\n\n"
|
||||
"A TechCorp Global ativou hoje novos clusters de datacenter com software avançado."
|
||||
)
|
||||
res = classifier.classify(ecp_tech_corp, content)
|
||||
|
||||
assert res.decision == DecisionCategory.DIRECT_INHERENT
|
||||
assert called["status"] is False # LLM NÃO foi acionado
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 5. Provedores Reais de LLM (OpenAI Mock e Gemini REST Mock)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def test_llm_provider_openai_client_execution(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 5.1: Simula execução bem-sucedida via cliente OpenAI SDK."""
|
||||
mock_chat_completion = MagicMock()
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = json.dumps(
|
||||
{
|
||||
"decision": "DIRECT_INHERENT",
|
||||
"confidence": 0.96,
|
||||
"rationale": "OpenAI validou o contexto corporativo com precisão.",
|
||||
}
|
||||
)
|
||||
mock_chat_completion.choices = [mock_choice]
|
||||
|
||||
mock_openai_instance = MagicMock()
|
||||
mock_openai_instance.chat.completions.create.return_value = mock_chat_completion
|
||||
|
||||
with patch("openai.OpenAI", return_value=mock_openai_instance):
|
||||
adapter = LLMFallbackAdapter(api_key="sk-mock-openai-key")
|
||||
initial_res = ClassificationResult(
|
||||
decision=DecisionCategory.TANGENTIAL,
|
||||
is_inherent=False,
|
||||
confidence=0.40,
|
||||
detected_language="pt",
|
||||
matched_anchors=[],
|
||||
negative_matches=[],
|
||||
graph_matches=[],
|
||||
evidence=[],
|
||||
rationale="Passing.",
|
||||
warnings=[],
|
||||
)
|
||||
res = adapter.disambiguate(ecp_tech_corp, "Artigo sobre TechCorp.", initial_res)
|
||||
assert res is not None
|
||||
assert res.decision == DecisionCategory.DIRECT_INHERENT
|
||||
assert res.confidence == 0.96
|
||||
|
||||
|
||||
def test_llm_provider_gemini_rest_execution(ecp_tech_corp: ECPSnapshot):
|
||||
"""Cenário 5.2: Simula execução bem-sucedida via API REST do Google Gemini."""
|
||||
gemini_payload = {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": json.dumps(
|
||||
{
|
||||
"decision": "DIRECT_INHERENT",
|
||||
"confidence": 0.98,
|
||||
"rationale": "Gemini 2.5 Flash confirmou aderência direta ao tópico.",
|
||||
}
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(gemini_payload).encode("utf-8")
|
||||
mock_response.__enter__.return_value = mock_response
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=mock_response):
|
||||
with patch.dict("os.environ", {"GEMINI_API_KEY": "mock-gemini-key"}):
|
||||
adapter = LLMFallbackAdapter(api_key="")
|
||||
initial_res = ClassificationResult(
|
||||
decision=DecisionCategory.TANGENTIAL,
|
||||
is_inherent=False,
|
||||
confidence=0.40,
|
||||
detected_language="pt",
|
||||
matched_anchors=[],
|
||||
negative_matches=[],
|
||||
graph_matches=[],
|
||||
evidence=[],
|
||||
rationale="Passing.",
|
||||
warnings=[],
|
||||
)
|
||||
res = adapter.disambiguate(ecp_tech_corp, "Artigo sobre TechCorp.", initial_res)
|
||||
assert res is not None
|
||||
assert res.decision == DecisionCategory.DIRECT_INHERENT
|
||||
assert res.confidence == 0.98
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 6. Suíte de Contrato e Erros da CLI classify.py
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def test_cli_error_ecp_file_does_not_exist(tmp_path: Path, capsys):
|
||||
"""Cenário 6.1: Caminho de ECP inexistente -> Exit Code 1, error_code: invalid_ecp_json."""
|
||||
content_file = tmp_path / "valid.md"
|
||||
content_file.write_text("# Conteúdo válido", encoding="utf-8")
|
||||
|
||||
exit_code = main(["--ecp", str(tmp_path / "nao_existe.json"), "--content", str(content_file)])
|
||||
assert exit_code == 1
|
||||
|
||||
captured = capsys.readouterr()
|
||||
err_json = json.loads(captured.err)
|
||||
assert err_json["error_code"] == "invalid_ecp_json"
|
||||
|
||||
|
||||
def test_cli_error_ecp_corrupted_json_syntax(tmp_path: Path, capsys):
|
||||
"""Cenário 6.2: Arquivo ECP com sintaxe JSON corrompida."""
|
||||
bad_ecp = tmp_path / "corrupt.json"
|
||||
bad_ecp.write_text("{ target_name: 'sem_aspas' ", encoding="utf-8")
|
||||
content_file = tmp_path / "valid.md"
|
||||
content_file.write_text("# Conteúdo válido", encoding="utf-8")
|
||||
|
||||
exit_code = main(["--ecp", str(bad_ecp), "--content", str(content_file)])
|
||||
assert exit_code == 1
|
||||
|
||||
captured = capsys.readouterr()
|
||||
err_json = json.loads(captured.err)
|
||||
assert err_json["error_code"] == "invalid_ecp_json"
|
||||
|
||||
|
||||
def test_cli_error_ecp_missing_each_required_field(tmp_path: Path, capsys):
|
||||
"""Cenário 6.3: Valida erro para falta de cada um dos campos obrigatórios do ECP."""
|
||||
required_fields = ["target_entity_id", "target_name", "aliases", "domain", "anchors"]
|
||||
|
||||
base_ecp = {
|
||||
"target_entity_id": "ent_1",
|
||||
"target_name": "Nome",
|
||||
"aliases": ["Alias"],
|
||||
"domain": "Domínio",
|
||||
"anchors": ["Âncora"],
|
||||
}
|
||||
content_file = tmp_path / "valid.md"
|
||||
content_file.write_text("# Conteúdo válido", encoding="utf-8")
|
||||
|
||||
for field in required_fields:
|
||||
bad_data = base_ecp.copy()
|
||||
del bad_data[field]
|
||||
bad_file = tmp_path / f"missing_{field}.json"
|
||||
bad_file.write_text(json.dumps(bad_data), encoding="utf-8")
|
||||
|
||||
exit_code = main(["--ecp", str(bad_file), "--content", str(content_file)])
|
||||
assert exit_code == 1
|
||||
|
||||
captured = capsys.readouterr()
|
||||
err_json = json.loads(captured.err)
|
||||
assert err_json["error_code"] == "missing_required_field"
|
||||
assert field in err_json["message"]
|
||||
|
||||
|
||||
def test_cli_error_content_file_does_not_exist(tmp_path: Path, capsys):
|
||||
"""Cenário 6.4: Caminho de arquivo Markdown inexistente."""
|
||||
ecp_file = tmp_path / "ecp.json"
|
||||
ecp_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"target_entity_id": "ent_1",
|
||||
"target_name": "TechCorp",
|
||||
"aliases": ["TechCorp"],
|
||||
"domain": "Tech",
|
||||
"anchors": ["cloud"],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
exit_code = main(["--ecp", str(ecp_file), "--content", str(tmp_path / "doc_fantasma.md")])
|
||||
assert exit_code == 1
|
||||
|
||||
captured = capsys.readouterr()
|
||||
err_json = json.loads(captured.err)
|
||||
assert err_json["error_code"] == "invalid_markdown"
|
||||
|
||||
|
||||
def test_cli_error_empty_and_whitespace_content(tmp_path: Path, capsys):
|
||||
"""Cenário 6.5: Arquivo Markdown vazio ou contendo apenas espaços em branco."""
|
||||
ecp_file = tmp_path / "ecp.json"
|
||||
ecp_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"target_entity_id": "ent_1",
|
||||
"target_name": "TechCorp",
|
||||
"aliases": ["TechCorp"],
|
||||
"domain": "Tech",
|
||||
"anchors": ["cloud"],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
for empty_text in ["", " \n\n\t \n "]:
|
||||
empty_file = tmp_path / "empty.md"
|
||||
empty_file.write_text(empty_text, encoding="utf-8")
|
||||
|
||||
exit_code = main(["--ecp", str(ecp_file), "--content", str(empty_file)])
|
||||
assert exit_code == 1
|
||||
|
||||
captured = capsys.readouterr()
|
||||
err_json = json.loads(captured.err)
|
||||
assert err_json["error_code"] == "empty_content"
|
||||
|
||||
|
||||
def test_cli_output_file_creates_nested_directories(tmp_path: Path):
|
||||
"""Cenário 6.6: A flag -o / --output cria diretórios aninhados automaticamente."""
|
||||
ecp_file = tmp_path / "ecp.json"
|
||||
ecp_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"target_entity_id": "ent_1",
|
||||
"target_name": "TechCorp",
|
||||
"aliases": ["TechCorp"],
|
||||
"domain": "Tech",
|
||||
"anchors": ["cloud"],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
content_file = tmp_path / "content.md"
|
||||
content_file.write_text("# TechCorp\n\nTechCorp cloud computing.", encoding="utf-8")
|
||||
|
||||
nested_out = tmp_path / "deep" / "nested" / "folder" / "resultado.json"
|
||||
|
||||
exit_code = main(
|
||||
["--ecp", str(ecp_file), "--content", str(content_file), "-o", str(nested_out)]
|
||||
)
|
||||
assert exit_code == 0
|
||||
assert nested_out.exists()
|
||||
|
||||
payload = json.loads(nested_out.read_text(encoding="utf-8"))
|
||||
assert payload["decision"] == "DIRECT_INHERENT"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 7. Matriz Multilíngue Completa (6 Idiomas)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lang,target,aliases,domain,anchors,content,expected_decision,expected_lang",
|
||||
[
|
||||
# Português
|
||||
(
|
||||
"pt",
|
||||
"Petrobras",
|
||||
["Petrobras"],
|
||||
"Energia",
|
||||
["pré-sal", "petróleo", "refinaria"],
|
||||
"# Petrobras bate recorde de produção no pré-sal com novas plataformas.",
|
||||
DecisionCategory.DIRECT_INHERENT,
|
||||
"pt",
|
||||
),
|
||||
# Inglês
|
||||
(
|
||||
"en",
|
||||
"Apple Inc.",
|
||||
["Apple", "Apple Inc."],
|
||||
"Technology",
|
||||
["iPhone", "MacBook", "iOS", "silicon"],
|
||||
"# Apple unveils new MacBook Pro with M4 silicon and advanced iOS features.",
|
||||
DecisionCategory.DIRECT_INHERENT,
|
||||
"en",
|
||||
),
|
||||
# Espanhol
|
||||
(
|
||||
"es",
|
||||
"River Plate",
|
||||
["River Plate", "River"],
|
||||
"Fútbol",
|
||||
["Monumental", "Libertadores", "Sudamericana"],
|
||||
"# River Plate se prepara para disputar el torneo continental en el Estadio Monumental.",
|
||||
DecisionCategory.DIRECT_INHERENT,
|
||||
"es",
|
||||
),
|
||||
# Alemão (Compostos e Diacríticos)
|
||||
(
|
||||
"de",
|
||||
"Volkswagen AG",
|
||||
["Volkswagen", "VW"],
|
||||
"Automobilindustrie",
|
||||
["Elektroauto", "Batteriefabrik", "Produktion"],
|
||||
"# Volkswagen investiert Milliarden in eine neue Batteriefabrik für Elektroautos in Deutschland.",
|
||||
DecisionCategory.DIRECT_INHERENT,
|
||||
"de",
|
||||
),
|
||||
# Italiano
|
||||
(
|
||||
"it",
|
||||
"Scuderia Ferrari",
|
||||
["Ferrari", "Scuderia Ferrari"],
|
||||
"Automobilismo",
|
||||
["Monza", "Gran Premio", "motore", "pole position"],
|
||||
"# La Ferrari conquista una straordinaria pole position nel Gran Premio di Monza.",
|
||||
DecisionCategory.DIRECT_INHERENT,
|
||||
"it",
|
||||
),
|
||||
# Francês (Elisão e Apóstrofos)
|
||||
(
|
||||
"fr",
|
||||
"TotalEnergies",
|
||||
["TotalEnergies", "Total"],
|
||||
"Énergie",
|
||||
["énergie solaire", "pétrole", "renouvelable", "électricité"],
|
||||
"# L'entreprise TotalEnergies accélère ses investissements dans l'énergie solaire et l'électricité en France.",
|
||||
DecisionCategory.DIRECT_INHERENT,
|
||||
"fr",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_multilingual_matrix_6_languages(
|
||||
lang: str,
|
||||
target: str,
|
||||
aliases: list[str],
|
||||
domain: str,
|
||||
anchors: list[str],
|
||||
content: str,
|
||||
expected_decision: DecisionCategory,
|
||||
expected_lang: str,
|
||||
):
|
||||
"""Garante a precisão e robustez do classificador nos 6 idiomas suportados pela POC."""
|
||||
ecp = ECPSnapshot(
|
||||
target_entity_id=f"ent_{lang}",
|
||||
target_name=target,
|
||||
aliases=aliases,
|
||||
domain=domain,
|
||||
anchors=anchors,
|
||||
)
|
||||
classifier = InherenceClassifier()
|
||||
res = classifier.classify(ecp, content)
|
||||
|
||||
assert res.decision == expected_decision
|
||||
assert res.is_inherent is True
|
||||
assert res.detected_language == expected_lang
|
||||
assert res.confidence >= 0.85
|
||||
Reference in New Issue
Block a user