feat(classifier): implement Tier 3 LLM fallback adapter and boundary disambiguation test suite

This commit is contained in:
2026-08-21 10:50:57 -03:00
parent cb33dafac1
commit 31152d5031
25 changed files with 2997 additions and 1404 deletions
+327
View File
@@ -0,0 +1,327 @@
"""
Suíte de Testes para o Adaptador de Fallback para LLM (Tier 3) do Classificador de Inerência.
Cobre cenários unitários, de integração de pipeline, de parsing estruturado, de desambiguação
de casos limiares e de degradação graciosa em falhas de API conforme o requisito FR-004.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from src.adapters.llm import LLMFallbackAdapter
from src.classifier import InherenceClassifier
from src.models import (
ClassificationResult,
DecisionCategory,
ECPSnapshot,
)
SCRIPT_PATH = Path(__file__).parent.parent / "classify.py"
# ==============================================================================
# 1. Testes Unitários do LLMFallbackAdapter
# ==============================================================================
def test_llm_adapter_availability_detection():
"""Valida detecção de disponibilidade por chave de API ou provider customizado."""
# Sem chave e sem provider
adapter_empty = LLMFallbackAdapter(api_key="")
assert adapter_empty.is_available() is False
# Com chave de API
adapter_with_key = LLMFallbackAdapter(api_key="sk-test-key-12345")
assert adapter_with_key.is_available() is True
# Com provider function
adapter_with_fn = LLMFallbackAdapter(
api_key="", provider_fn=lambda p: '{"decision": "DIRECT_INHERENT"}'
)
assert adapter_with_fn.is_available() is True
def test_llm_adapter_build_prompt_structure():
"""Valida a montagem do prompt de desambiguação com metadados do ECP e documento."""
adapter = LLMFallbackAdapter(api_key="test")
ecp = ECPSnapshot(
target_entity_id="ecp_river",
target_name="River Plate",
aliases=["Club Atlético River Plate", "CARP"],
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.",
warnings=[],
)
prompt = adapter.build_prompt(ecp, "# Título do Artigo\n\nConteúdo sobre o jogo.", initial_res)
assert "Target Entity: River Plate" in prompt
assert "Futebol" in prompt
assert "TANGENTIAL" in prompt
assert "Título do Artigo" in prompt
def test_llm_adapter_parsing_valid_json_response():
"""Valida o parsing e instanciação correta do ClassificationResult a partir da resposta do LLM."""
adapter = LLMFallbackAdapter(
provider_fn=lambda p: json.dumps(
{
"decision": "DIRECT_INHERENT",
"confidence": 0.95,
"rationale": "Artigo detalha o desempenho da equipe no torneio.",
}
)
)
ecp = ECPSnapshot(
target_entity_id="ecp_test",
target_name="Test Entity",
aliases=["Test"],
domain="Tech",
anchors=["cloud"],
)
initial = ClassificationResult(
decision=DecisionCategory.TANGENTIAL,
is_inherent=False,
confidence=0.40,
detected_language="pt",
matched_anchors=["Test"],
negative_matches=[],
graph_matches=[],
evidence=["Test"],
rationale="Weak match.",
warnings=["Low contextual density."],
)
refined = adapter.disambiguate(ecp, "Document content...", initial)
assert refined is not None
assert refined.decision == DecisionCategory.DIRECT_INHERENT
assert refined.is_inherent is True
assert refined.confidence == 0.95
assert "[Tier 3 LLM]" in refined.rationale
assert "[Tier 3 LLM Override applied]" in refined.warnings
def test_llm_adapter_parsing_json_wrapped_in_markdown_codeblock():
"""Valida extração de JSON quando a resposta do LLM vem formatada em bloco markdown ```json ... ```."""
raw_md_json = '```json\n{\n "decision": "CONTEXTUAL_INHERENT",\n "confidence": 0.88,\n "rationale": "Conexão contextual forte através da subsidiária."\n}\n```'
adapter = LLMFallbackAdapter(provider_fn=lambda p: raw_md_json)
ecp = ECPSnapshot(
target_entity_id="ecp_test",
target_name="Test Entity",
aliases=["Test"],
domain="Tech",
anchors=["cloud"],
)
initial = ClassificationResult(
decision=DecisionCategory.TANGENTIAL,
is_inherent=False,
confidence=0.40,
detected_language="pt",
matched_anchors=[],
negative_matches=[],
graph_matches=[],
evidence=[],
rationale="Weak match.",
warnings=[],
)
refined = adapter.disambiguate(ecp, "Content...", initial)
assert refined is not None
assert refined.decision == DecisionCategory.CONTEXTUAL_INHERENT
assert refined.is_inherent is True
assert refined.confidence == 0.88
def test_llm_adapter_handling_invalid_and_corrupt_responses():
"""Valida que respostas corrompidas ou JSONs sem campos obrigatórios retornem None com segurança."""
def make_bad_provider(resp_str: str):
def _prov(prompt: str) -> str:
return resp_str
return _prov
for bad_response in [
"Desculpe, não consegui avaliar o texto.",
"{json_invalido_sem_fechamento",
json.dumps({"campo_desconhecido": "valor"}),
json.dumps({"decision": "DECISAO_INEXISTENTE"}),
]:
adapter = LLMFallbackAdapter(provider_fn=make_bad_provider(bad_response))
ecp = ECPSnapshot(
target_entity_id="ecp_test",
target_name="Test Entity",
aliases=["Test"],
domain="Tech",
anchors=["cloud"],
)
initial = ClassificationResult(
decision=DecisionCategory.TANGENTIAL,
is_inherent=False,
confidence=0.40,
detected_language="pt",
matched_anchors=[],
negative_matches=[],
graph_matches=[],
evidence=[],
rationale="Initial.",
warnings=[],
)
assert adapter.disambiguate(ecp, "Content...", initial) is None
# ==============================================================================
# 2. Testes de Integração de Pipeline (InherenceClassifier com Tier 3)
# ==============================================================================
def test_classifier_triggers_tier3_on_ambiguous_tangential_case():
"""
Garante que o classificador dispare o Tier 3 LLM para casos ambíguos (TANGENTIAL)
e adote o refinamento retornado.
"""
mock_adapter = LLMFallbackAdapter(
provider_fn=lambda prompt: json.dumps(
{
"decision": "DIRECT_INHERENT",
"confidence": 0.92,
"rationale": "Análise profunda revelou que o texto é focado na entidade alvo.",
}
)
)
classifier = InherenceClassifier(enable_llm=True, llm_adapter=mock_adapter)
ecp = ECPSnapshot(
target_entity_id="ecp_empresa",
target_name="EmpresaAlfa",
aliases=["EmpresaAlfa"],
domain="Tecnologia",
anchors=["software", "computação em nuvem"],
)
# Texto com menção única sem âncoras temáticas (Tier 1 produziria TANGENTIAL)
ambiguous_content = "A EmpresaAlfa esteve presente no evento de encerramento anual da cidade."
result = classifier.classify(ecp, ambiguous_content)
# Como enable_llm=True e o caso era TANGENTIAL, o Tier 3 substitui a decisão
assert result.decision == DecisionCategory.DIRECT_INHERENT
assert result.is_inherent is True
assert result.confidence == 0.92
assert "[Tier 3 LLM]" in result.rationale
def test_classifier_skips_tier3_on_clear_direct_inherent_case():
"""
Garante que casos claros (alta confiança e alta densidade de âncoras) NÃO chamem
o LLM, economizando chamadas desnecessárias conforme FR-004.
"""
call_tracker = {"called": False}
def tracking_provider(prompt: str) -> str:
call_tracker["called"] = True
return json.dumps({"decision": "DIRECT_INHERENT", "confidence": 0.99})
mock_adapter = LLMFallbackAdapter(provider_fn=tracking_provider)
classifier = InherenceClassifier(enable_llm=True, llm_adapter=mock_adapter)
ecp = ECPSnapshot(
target_entity_id="ecp_empresa",
target_name="EmpresaAlfa",
aliases=["EmpresaAlfa"],
domain="Tecnologia",
anchors=["software", "computação em nuvem", "inteligência artificial"],
)
# Caso claro com alta densidade de âncoras
clear_content = "A EmpresaAlfa desenvolveu uma nova plataforma de software baseada em computação em nuvem e inteligência artificial."
result = classifier.classify(ecp, clear_content)
assert result.decision == DecisionCategory.DIRECT_INHERENT
assert result.confidence >= 0.85
# O LLM NÃO deve ter sido chamado
assert call_tracker["called"] is False
def test_classifier_graceful_degradation_when_llm_raises_exception():
"""
Garante que se o LLM falhar por erro de rede ou timeout, o classificador mantenha
o resultado do Tier 1 com degradação graciosa e registre o aviso em warnings.
"""
def failing_provider(prompt: str) -> str:
raise ConnectionError("Timeout ao conectar com a API do modelo de linguagem.")
mock_adapter = LLMFallbackAdapter(provider_fn=failing_provider)
classifier = InherenceClassifier(enable_llm=True, llm_adapter=mock_adapter)
ecp = ECPSnapshot(
target_entity_id="ecp_empresa",
target_name="EmpresaAlfa",
aliases=["EmpresaAlfa"],
domain="Tecnologia",
anchors=["software"],
)
ambiguous_content = "A EmpresaAlfa participou da conferência."
result = classifier.classify(ecp, ambiguous_content)
# Retém a decisão original do Tier 1
assert result.decision == DecisionCategory.TANGENTIAL
assert result.is_inherent is False
# Contém aviso sobre a falha do LLM sem quebrar a execução
assert any("LLM fallback failed" in w for w in result.warnings)
# ==============================================================================
# 3. Teste de Integração CLI com a Flag --enable-llm
# ==============================================================================
def test_cli_execution_with_enable_llm_flag(tmp_path):
"""Garante que a CLI classify.py aceite e processe a flag --enable-llm sem erros."""
ecp_file = tmp_path / "test_ecp.json"
ecp_file.write_text(
json.dumps(
{
"target_entity_id": "ecp_test",
"target_name": "TestCorp",
"aliases": ["TestCorp"],
"domain": "Tech",
"anchors": ["software", "cloud"],
}
),
encoding="utf-8",
)
content_file = tmp_path / "test_doc.md"
content_file.write_text("# TestCorp\n\nTestCorp builds cloud software.", encoding="utf-8")
output_file = tmp_path / "out.json"
res = subprocess.run(
[
sys.executable,
str(SCRIPT_PATH),
"--ecp",
str(ecp_file),
"--content",
str(content_file),
"--output",
str(output_file),
"--enable-llm",
],
capture_output=True,
text=True,
)
assert res.returncode == 0, f"Erro na CLI: {res.stderr}"
assert output_file.exists()
data = json.loads(output_file.read_text(encoding="utf-8"))
assert data["decision"] == "DIRECT_INHERENT"
assert data["is_inherent"] is True