330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""
|
|
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.tools.adapters.llm import LLMFallbackAdapter
|
|
from src.tools.classifier import InherenceClassifier
|
|
from src.tools.models import (
|
|
ClassificationResult,
|
|
DecisionCategory,
|
|
ECPSnapshot,
|
|
)
|
|
|
|
SCRIPT_PATH = Path(__file__).parent.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 "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
|