""" 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

Reunião do Copom

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")