feat(classifier): implement Tier 3 LLM fallback adapter and boundary disambiguation test suite
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user