"""
Testes End-to-End (E2E) reais com Ollama e modelo qwen3.5:2b ao vivo.
Executa o funil completo de ponta a ponta:
1. Comunicação HTTP real com Ollama local (qwen3.5:2b);
2. Classificação semântica real para publicações de mídia vs texto;
3. Execução de lote com Foxcape + Ollama + Multimotor (Trafilatura, Newspaper4k, Readability);
4. Validação dos arquivos de saída (*_extracted.json e *_media.json).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.request
from pathlib import Path
import pytest
from bs4 import BeautifulSoup
# Adiciona a raiz do projeto ao path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from scripts.extract_article_contents import (
MediaCandidateInfo,
MediaClassification,
build_compact_payload,
classify_media_content,
detect_candidate_media,
process_batch,
)
def is_ollama_available() -> bool:
"""Verifica se o servidor Ollama está ativo localmente e com qwen3.5:2b disponível."""
try:
url = os.environ.get("OLLAMA_ENDPOINT", "http://localhost:11434").rstrip("/") + "/api/tags"
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=3) as resp:
if resp.status == 200:
data = json.loads(resp.read().decode("utf-8"))
models = [m.get("name", "") for m in data.get("models", [])]
return any("qwen3.5:2b" in m for m in models)
except Exception:
return False
return False
@pytest.mark.skipif(not is_ollama_available(), reason="Ollama local com qwen3.5:2b não está acessível")
def test_live_ollama_video_media_classification() -> None:
"""Valida classificação real via Ollama para publicação de vídeo esportivo com texto descritivo curto."""
html = """
Golaço de falta na Copa Libertadores - Melhores Momentos
Golaço de falta na Copa Libertadores
Confira no vídeo acima o golaço de falta marcado no último minuto da partida.
"""
soup = BeautifulSoup(html, "html.parser")
candidate_info = detect_candidate_media(soup)
assert candidate_info.has_candidate_media is True
assert candidate_info.has_video is True
payload = build_compact_payload(soup, candidate_info)
metrics = {
"fallback_groq": 0,
"fallback_omniroute": 0,
"classification_failed": 0,
}
classification, err = classify_media_content(payload, metrics, silent=False)
assert err is None
assert classification is not None
assert classification.content_type == "media"
assert classification.media_type in ("video", "mixed")
# Nenhum fallback deve ter sido acionado
assert metrics["fallback_groq"] == 0
assert metrics["fallback_omniroute"] == 0
@pytest.mark.skipif(not is_ollama_available(), reason="Ollama local com qwen3.5:2b não está acessível")
def test_live_ollama_substantive_textual_article_classification() -> None:
"""Valida classificação real via Ollama para matéria jornalística densa acompanhada de foto ilustrativa."""
html = """
Banco Central mantém taxa de juros e sinaliza cautela econômica
Banco Central mantém taxa de juros e sinaliza cautela econômica
O Comitê de Política Monetária (Copom) do Banco Central decidiu por unanimidade manter a taxa básica de juros (Selic) inalterada, conforme amplamente esperado pelos analistas de mercado.
Em seu comunicado oficial, a autoridade monetária destacou que o cenário global permanece volátil e que a conjuntura doméstica exige perseverança na condução da política monetária para consolidar a desinflação.
Economistas ouvidos pelo relatório Focus projetam que os primeiros cortes poderão ocorrer no próximo trimestre, caso as expectativas de inflação continuem ancoradas nas metas estabelecidas pelo Conselho Monetário Nacional.
"""
soup = BeautifulSoup(html, "html.parser")
candidate_info = detect_candidate_media(soup)
assert candidate_info.has_candidate_media is True
assert candidate_info.image_count == 1
payload = build_compact_payload(soup, candidate_info)
metrics = {
"fallback_groq": 0,
"fallback_omniroute": 0,
"classification_failed": 0,
}
classification, err = classify_media_content(payload, metrics, silent=False)
assert err is None
assert classification is not None
assert classification.content_type == "text"
assert classification.media_type is None
assert metrics["fallback_groq"] == 0
@pytest.mark.skipif(not is_ollama_available(), reason="Ollama local com qwen3.5:2b não está acessível")
def test_live_e2e_batch_funnel_with_ollama_and_multimotor(tmp_path: Path) -> None:
"""
Executa o funil completo em lote:
- 1 artigo textual puro (sem mídia) -> bypass direto do gate -> extraído pelos 3 motores
- 1 artigo predominantemente de mídia (vídeo) -> classificado pelo Ollama ao vivo -> desviado para *_media.json
- 1 artigo textual longo com foto ilustrativa -> classificado pelo Ollama ao vivo -> extraído pelos 3 motores
"""
input_file = tmp_path / "batch_funnel.json"
batch_data = {
"query": "noticias ao vivo",
"language": "pt",
"items": [
{
"titulo": "Artigo Textual Puro",
"url": "https://example.com/texto-puro",
"custom_id": "item-001",
},
{
"titulo": "Vídeo Highlights do Jogo",
"url": "https://example.com/video-highlights",
"custom_id": "item-002",
},
{
"titulo": "Análise Econômica com Foto",
"url": "https://example.com/analise-economica",
"custom_id": "item-003",
},
],
}
with open(input_file, "w", encoding="utf-8") as f:
json.dump(batch_data, f, ensure_ascii=False, indent=2)
html_puro = """
Artigo Textual Puro
Primeiro parágrafo longo e substancial sobre política pública e saúde coletiva.
Segundo parágrafo detalhando as medidas sanitárias aprovadas pelo congresso.
"""
html_video = """
Vídeo Highlights do Jogo
Veja o resumo em vídeo com os melhores momentos da rodada esportiva.
"""
html_foto = """
Análise Econômica com Foto
O mercado de capitais registrou forte valorização nesta segunda-feira impulsionado por dados positivos do setor industrial.
As ações das principais empresas de commodities lideraram os ganhos no pregão, enquanto o câmbio operou em estabilidade.
Investidores estrangeiros aportaram mais de 500 milhões de reais no mercado à vista ao longo da última semana.
"""
def mock_crawl(url: str) -> tuple[str, str, int]:
if "texto-puro" in url:
return html_puro, "Artigo Textual Puro", 200
elif "video-highlights" in url:
return html_video, "Vídeo Highlights do Jogo", 200
else:
return html_foto, "Análise Econômica com Foto", 200
from unittest.mock import patch, MagicMock
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler:
crawler = MagicMock()
crawler.__enter__.return_value = crawler
crawler.__exit__.return_value = None
crawler.crawl.side_effect = mock_crawl
MockCrawler.return_value = crawler
# Executa process_batch usando o Ollama real em localhost:11434
report = process_batch(input_file, silent=False)
# 1. Verifica arquivo textual *_extracted.json
# Devem existir 2 artigos textuais no relatório principal (item-001 e item-003)
assert report.total_articles == 2
assert report.successful_articles == 2
assert report.failed_articles == 0
assert len(report.articles) == 2
# Preservação de input_meta com custom_id nos artigos textuais
assert report.articles[0].input_meta.to_dict()["custom_id"] == "item-001"
assert report.articles[1].input_meta.to_dict()["custom_id"] == "item-003"
# 2. Verifica arquivo de mídia *_media.json
# Deve conter exatamente 1 artigo desviado (item-002)
media_file = tmp_path / "batch_funnel_media.json"
assert media_file.exists()
with open(media_file, "r", encoding="utf-8") as f:
media_data = json.load(f)
assert len(media_data["articles"]) == 1
media_item = media_data["articles"][0]
assert media_item["input_meta"]["custom_id"] == "item-002"
assert media_item["content_type"] == "media"
assert media_item["media_type"] in ("video", "mixed")