feat(runtime): implement single-article consolidation runtime and modularize codebase

This commit is contained in:
2026-08-24 00:14:07 -03:00
parent e1e0be1353
commit 23de7d8fe7
176 changed files with 266754 additions and 10179 deletions
@@ -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.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 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