feat(media-routing): implement 007 media article routing, runtime architecture diagram and update graphify knowledge graph

This commit is contained in:
2026-08-25 01:20:49 -03:00
parent d2d1aad001
commit 47b5215541
45 changed files with 290741 additions and 68310 deletions
@@ -0,0 +1,225 @@
"""
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 = """
<html>
<head><title>Golaço de falta na Copa Libertadores - Melhores Momentos</title></head>
<body>
<article>
<h1>Golaço de falta na Copa Libertadores</h1>
<video controls src="https://example.com/videos/gol.mp4"></video>
<p>Confira no vídeo acima o golaço de falta marcado no último minuto da partida.</p>
</article>
</body>
</html>
"""
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 = """
<html>
<head><title>Banco Central mantém taxa de juros e sinaliza cautela econômica</title></head>
<body>
<article>
<h1>Banco Central mantém taxa de juros e sinaliza cautela econômica</h1>
<img src="https://example.com/fotos/reuniao_copom.jpg" alt="Reunião do Copom">
<p>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.</p>
<p>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.</p>
<p>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.</p>
</article>
</body>
</html>
"""
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 = """
<html><body><article>
<h1>Artigo Textual Puro</h1>
<p>Primeiro parágrafo longo e substancial sobre política pública e saúde coletiva.</p>
<p>Segundo parágrafo detalhando as medidas sanitárias aprovadas pelo congresso.</p>
</article></body></html>
"""
html_video = """
<html><body><article>
<h1>Vídeo Highlights do Jogo</h1>
<video src="highlights.mp4"></video>
<p>Veja o resumo em vídeo com os melhores momentos da rodada esportiva.</p>
</article></body></html>
"""
html_foto = """
<html><body><article>
<h1>Análise Econômica com Foto</h1>
<img src="foto_grafico.jpg">
<p>O mercado de capitais registrou forte valorização nesta segunda-feira impulsionado por dados positivos do setor industrial.</p>
<p>As ações das principais empresas de commodities lideraram os ganhos no pregão, enquanto o câmbio operou em estabilidade.</p>
<p>Investidores estrangeiros aportaram mais de 500 milhões de reais no mercado à vista ao longo da última semana.</p>
</article></body></html>
"""
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")
+567
View File
@@ -0,0 +1,567 @@
"""
Testes de integração para roteamento de artigos de mídia e texto.
Cobre os Cenários B a F (User Story 1), resolução de caminhos padrão e com -o,
e preservação integral de metadados em input_meta com campos customizados.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
from unittest.mock import patch, MagicMock
import pytest
# Adiciona o diretório raiz ao sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from scripts.extract_article_contents import process_batch, ArticleCrawler
@pytest.fixture
def sample_input_json(tmp_path: Path) -> Path:
"""Cria um arquivo de entrada de busca sintético para testes."""
input_file = tmp_path / "search_sample.json"
data = {
"query": "noticias futebol",
"language": "pt",
"items": [
{
"titulo": "Gol Histórico do River Plate",
"url": "https://example.com/video-gol",
"subtitulo": "Veja o lance em vídeo",
"quando_publicado": "2026-08-24T12:00:00Z",
"pagina": 1,
"custom_field": "preserve-me",
"custom_number": 42,
}
],
}
with open(input_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return input_file
def mock_crawl_html(html_body: str, title: str = "Página Notícia", status_code: int = 200) -> MagicMock:
mock = MagicMock()
mock.__enter__.return_value = mock
mock.__exit__.return_value = None
mock.crawl.return_value = (html_body, title, status_code)
return mock
def test_media_routing_scenario_b_video(tmp_path: Path) -> None:
"""Cenário B: Publicação com vídeo e texto curto descritivo -> roteada para *_media.json."""
input_file = tmp_path / "river_plate.json"
input_data = {
"query": "river plate",
"language": "es",
"items": [
{
"titulo": "Video del Golazo",
"url": "https://example.com/video",
"custom_tracker_id": "trk-999",
}
],
}
with open(input_file, "w", encoding="utf-8") as f:
json.dump(input_data, f)
html = """
<html>
<body>
<article>
<h1>Video del Golazo</h1>
<video src="gol.mp4"></video>
<p>Mira el resumen del partido.</p>
</article>
</body>
</html>
"""
# Mock crawler e mock LLM (Ollama respondendo content_type=media, media_type=video)
mock_llm_response = (
200,
json.dumps(
{
"message": {
"content": json.dumps({"content_type": "media", "media_type": "video"})
}
}
),
)
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler, \
patch("scripts.extract_article_contents._http_post_json", return_value=mock_llm_response), \
patch("scripts.extract_article_contents.extract_all_engines") as mock_extractors:
crawler_instance = mock_crawl_html(html, title="Video del Golazo", status_code=200)
MockCrawler.return_value = crawler_instance
report = process_batch(input_file, silent=True)
# Multimotor NÃO deve ter sido executado
assert mock_extractors.call_count == 0
# Relatório textual deve ter 0 artigos no JSON principal
assert report.total_articles == 0
assert report.successful_articles == 0
assert len(report.articles) == 0
# Arquivo de mídia river_plate_media.json deve ter sido criado
media_file = tmp_path / "river_plate_media.json"
assert media_file.exists()
with open(media_file, "r", encoding="utf-8") as f:
media_data = json.load(f)
assert "articles" in media_data
assert len(media_data["articles"]) == 1
article = media_data["articles"][0]
assert article["content_type"] == "media"
assert article["media_type"] == "video"
assert article["crawled_url"] == "https://example.com/video"
assert article["http_status"] == 200
assert article["input_meta"]["titulo"] == "Video del Golazo"
assert article["input_meta"]["custom_tracker_id"] == "trk-999"
def test_media_routing_scenarios_c_d_e_f(tmp_path: Path) -> None:
"""Cenários C, D, E, F: image, images, embed, mixed."""
cases = [
("image", "<article><img src='foto.jpg'><p>Legenda curta.</p></article>"),
("images", "<article><img src='1.jpg'><img src='2.jpg'><p>Galeria.</p></article>"),
("embed", "<article><iframe src='insta.com/p/123'></iframe><p>Post.</p></article>"),
("mixed", "<article><video src='v.mp4'></video><img src='f.jpg'><p>Misto.</p></article>"),
]
for media_type, html in cases:
input_file = tmp_path / f"test_{media_type}.json"
with open(input_file, "w", encoding="utf-8") as f:
json.dump(
{
"items": [
{
"titulo": f"Titulo {media_type}",
"url": f"https://example.com/{media_type}",
}
]
},
f,
)
mock_llm_response = (
200,
json.dumps(
{
"message": {
"content": json.dumps({"content_type": "media", "media_type": media_type})
}
}
),
)
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler, \
patch("scripts.extract_article_contents._http_post_json", return_value=mock_llm_response), \
patch("scripts.extract_article_contents.extract_all_engines") as mock_extractors:
MockCrawler.return_value = mock_crawl_html(html)
process_batch(input_file, silent=True)
assert mock_extractors.call_count == 0
media_file = tmp_path / f"test_{media_type}_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
assert media_data["articles"][0]["media_type"] == media_type
def test_media_routing_custom_output_path_resolution(tmp_path: Path) -> None:
"""Valida resolução de caminho com -o customizado: out/processados/resultado.json -> resultado_media.json."""
input_file = tmp_path / "raw_input.json"
with open(input_file, "w", encoding="utf-8") as f:
json.dump({"items": [{"titulo": "T", "url": "https://example.com/item"}]}, f)
custom_out = tmp_path / "custom_dir" / "saida_final.json"
html = "<article><video src='v.mp4'></video><p>Texto curto.</p></article>"
mock_llm_response = (
200,
json.dumps(
{"message": {"content": json.dumps({"content_type": "media", "media_type": "video"})}}
),
)
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler, \
patch("scripts.extract_article_contents._http_post_json", return_value=mock_llm_response):
MockCrawler.return_value = mock_crawl_html(html)
process_batch(input_file, output_path=custom_out, silent=True)
assert custom_out.exists()
expected_media_file = tmp_path / "custom_dir" / "saida_final_media.json"
assert expected_media_file.exists()
def test_textual_routing_scenario_a_no_media_bypasses_llm(tmp_path: Path) -> None:
"""Cenário A: Artigo sem mídia candidata segue direto ao multimotor sem chamar LLM."""
input_file = tmp_path / "pure_text.json"
with open(input_file, "w", encoding="utf-8") as f:
json.dump(
{
"items": [
{
"titulo": "Notícia Textual Sem Mídia",
"url": "https://example.com/texto-puro",
"custom_field": "preserve-me",
"custom_number": 42,
}
]
},
f,
)
html = "<article><h1>Texto Puro</h1><p>Notícia densa com múltiplos parágrafos.</p></article>"
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler, \
patch("scripts.extract_article_contents._http_post_json") as mock_http, \
patch("scripts.extract_article_contents.extract_all_engines") as mock_extractors:
MockCrawler.return_value = mock_crawl_html(html)
mock_extractors.return_value = (None, None, None)
report = process_batch(input_file, silent=True)
# LLM NÃO deve ser chamado (bypass)
assert mock_http.call_count == 0
# Multimotor DEVE ser chamado
assert mock_extractors.call_count == 1
# JSON textual principal contém o artigo
assert report.total_articles == 1
assert report.successful_articles == 1
assert report.failed_articles == 0
assert len(report.articles) == 1
assert report.articles[0].input_meta.to_dict()["custom_field"] == "preserve-me"
assert report.articles[0].input_meta.to_dict()["custom_number"] == 42
# *_media.json MUST ser gerado com {"articles": []}
media_file = tmp_path / "pure_text_media.json"
assert media_file.exists()
with open(media_file, "r", encoding="utf-8") as f:
media_data = json.load(f)
assert media_data == {"articles": []}
def test_textual_routing_scenarios_g_and_h_with_illustrative_media(tmp_path: Path) -> None:
"""Cenários G e H: Notícias longas com imagem/vídeo ilustrativo recebem content_type=text."""
cases = [
("cenario_g_image", "<article><img src='foto.jpg'><p>Texto jornalístico longo e substancial 1.</p><p>Texto longo 2.</p></article>"),
("cenario_h_video", "<article><video src='v.mp4'></video><p>Texto jornalístico longo e substancial 1.</p><p>Texto longo 2.</p></article>"),
]
mock_llm_text_response = (
200,
json.dumps(
{"message": {"content": json.dumps({"content_type": "text", "media_type": None})}}
),
)
for case_name, html in cases:
input_file = tmp_path / f"{case_name}.json"
with open(input_file, "w", encoding="utf-8") as f:
json.dump(
{
"items": [
{
"titulo": f"Matéria {case_name}",
"url": f"https://example.com/{case_name}",
"custom_author": "Repórter Especial",
}
]
},
f,
)
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler, \
patch("scripts.extract_article_contents._http_post_json", return_value=mock_llm_text_response) as mock_http, \
patch("scripts.extract_article_contents.extract_all_engines") as mock_extractors:
MockCrawler.return_value = mock_crawl_html(html)
mock_extractors.return_value = (None, None, None)
report = process_batch(input_file, silent=True)
# LLM foi chamado pois havia mídia candidata
assert mock_http.call_count == 1
# Multimotor foi executado pois a classificação retornou text
assert mock_extractors.call_count == 1
assert report.total_articles == 1
assert report.successful_articles == 1
assert report.articles[0].input_meta.to_dict()["custom_author"] == "Repórter Especial"
# *_media.json gerado incondicionalmente vazio
media_file = tmp_path / f"{case_name}_media.json"
assert media_file.exists()
with open(media_file, "r", encoding="utf-8") as f:
media_data = json.load(f)
assert media_data == {"articles": []}
# ==============================================================================
# Testes de Integração de Fallback e Falha Cumulativa (User Story 3 / T016)
# ==============================================================================
def test_scenario_k_cumulative_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Cenário K: Falha em todos os 3 provedores -> registro inline de falha no JSON principal."""
monkeypatch.setenv("GROQ_API_KEY", "test-key")
monkeypatch.setenv("OMNIROUTE_ENDPOINT", "http://omniroute.test")
input_file = tmp_path / "fail_input.json"
with open(input_file, "w", encoding="utf-8") as f:
json.dump(
{
"items": [
{
"titulo": "Artigo com Falha de LLM",
"url": "https://example.com/fail-item",
"custom_id": "cust-77",
}
]
},
f,
)
html = "<article><video src='v.mp4'></video><p>Texto.</p></article>"
def mock_http_fail(*args: Any, **kwargs: Any) -> tuple[int, str]:
raise ConnectionResetError("Connection reset")
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler, \
patch("scripts.extract_article_contents._http_post_json", side_effect=mock_http_fail), \
patch("scripts.extract_article_contents.extract_all_engines") as mock_extractors:
MockCrawler.return_value = mock_crawl_html(html, title="Artigo com Falha de LLM", status_code=200)
report = process_batch(input_file, silent=True)
# Multimotor NÃO deve ter sido executado
assert mock_extractors.call_count == 0
# Artigo presente no JSON textual como falha
assert report.total_articles == 1
assert report.successful_articles == 0
assert report.failed_articles == 1
assert len(report.articles) == 1
failed_art = report.articles[0]
assert failed_art.classification_status == "failed"
assert failed_art.error_message is not None
assert failed_art.crawled_url == "https://example.com/fail-item"
assert failed_art.page_title == "Artigo com Falha de LLM"
assert failed_art.http_status == 200
assert failed_art.input_meta.to_dict()["custom_id"] == "cust-77"
assert failed_art.trafilatura is None
assert failed_art.newspaper4k is None
assert failed_art.readability is None
# NÃO deve estar em *_media.json
media_file = tmp_path / "fail_input_media.json"
assert media_file.exists()
with open(media_file, "r", encoding="utf-8") as f:
media_data = json.load(f)
assert media_data == {"articles": []}
def test_scenario_i_j_l_fallbacks_and_multilingual(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Cenários I, J, L e M: Transição para Groq/OmniRoute e suporte multilíngue (pt, es, en)."""
monkeypatch.setenv("GROQ_API_KEY", "groq-key")
monkeypatch.setenv("OMNIROUTE_ENDPOINT", "http://omniroute.test")
input_file = tmp_path / "multilingual.json"
items = [
{"titulo": "Notícia em Português", "url": "https://example.com/pt", "lang": "pt"},
{"titulo": "Noticia en Español", "url": "https://example.com/es", "lang": "es"},
{"titulo": "English News Article", "url": "https://example.com/en", "lang": "en"},
]
with open(input_file, "w", encoding="utf-8") as f:
json.dump({"items": items}, f)
html = "<article><video src='v.mp4'></video><p>Texto.</p></article>"
# 1º item: Ollama falha, Groq retorna media
# 2º item: Ollama falha, Groq falha, OmniRoute retorna media
# 3º item: Ollama falha, Groq retorna text -> vai para multimotor
step_state = {"call_idx": 0}
def mock_http_chain(url: str, payload: dict, headers: dict, timeout: int) -> tuple[int, str]:
if "11434" in url:
# Ollama sempre falha
raise TimeoutError("Ollama timeout")
if "groq.com" in url:
step_state["call_idx"] += 1
if step_state["call_idx"] == 1:
# 1º item via Groq: media
return 200, json.dumps({"choices": [{"message": {"content": json.dumps({"content_type": "media", "media_type": "video"})}}]})
if step_state["call_idx"] == 2:
# 2º item: Groq falha
raise RuntimeError("Groq rate limit")
if step_state["call_idx"] >= 3:
# 3º item via Groq: text
return 200, json.dumps({"choices": [{"message": {"content": json.dumps({"content_type": "text", "media_type": None})}}]})
if "omniroute.test" in url:
# OmniRoute responde media para o 2º item
return 200, json.dumps({"choices": [{"message": {"content": json.dumps({"content_type": "media", "media_type": "embed"})}}]})
return 500, "error"
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler, \
patch("scripts.extract_article_contents._http_post_json", side_effect=mock_http_chain), \
patch("scripts.extract_article_contents.extract_all_engines") as mock_extractors:
MockCrawler.return_value = mock_crawl_html(html)
mock_extractors.return_value = (None, None, None)
report = process_batch(input_file, silent=True)
# 3º item foi para multimotor
assert mock_extractors.call_count == 1
assert report.total_articles == 1
assert report.successful_articles == 1
assert report.articles[0].input_meta.to_dict()["lang"] == "en"
# 1º e 2º itens foram para *_media.json
media_file = tmp_path / "multilingual_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"]) == 2
assert media_data["articles"][0]["input_meta"]["lang"] == "pt"
assert media_data["articles"][0]["media_type"] == "video"
assert media_data["articles"][1]["input_meta"]["lang"] == "es"
assert media_data["articles"][1]["media_type"] == "embed"
# ==============================================================================
# Testes de Observabilidade, Segurança e CLI (Phase 5 / T019)
# ==============================================================================
def test_metrics_values_and_stderr_summary_logging(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Valida valores das métricas e emissão formatada no stderr quando silent=False."""
input_file = tmp_path / "metrics_test.json"
items = [
{"titulo": "Texto Puro", "url": "https://example.com/puro"},
{"titulo": "Video Direct", "url": "https://example.com/video"},
]
with open(input_file, "w", encoding="utf-8") as f:
json.dump({"items": items}, f)
def mock_crawl(url: str) -> tuple[str, str, int]:
if "puro" in url:
return "<article><p>Densa matéria pura.</p></article>", "Puro", 200
return "<article><video src='v.mp4'></video><p>Vídeo.</p></article>", "Vídeo", 200
def mock_http(url: str, payload: dict, headers: dict, timeout: int) -> tuple[int, str]:
return 200, json.dumps({"message": {"content": json.dumps({"content_type": "media", "media_type": "video"})}})
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler, \
patch("scripts.extract_article_contents._http_post_json", side_effect=mock_http), \
patch("scripts.extract_article_contents.extract_all_engines", return_value=(None, None, None)):
crawler = MagicMock()
crawler.__enter__.return_value = crawler
crawler.__exit__.return_value = None
crawler.crawl.side_effect = mock_crawl
MockCrawler.return_value = crawler
process_batch(input_file, silent=False)
captured = capsys.readouterr()
assert "[MEDIA] Métricas de Roteamento:" in captured.err
assert "Total avaliados: 2" in captured.err
assert "Texto: 1" in captured.err
assert "Mídia: 1" in captured.err
assert "video=1" in captured.err
def test_silent_mode_suppresses_all_media_logs(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Valida que flag silent=True suprime todos os logs em stderr."""
input_file = tmp_path / "silent_test.json"
with open(input_file, "w", encoding="utf-8") as f:
json.dump({"items": [{"titulo": "T", "url": "https://example.com/item"}]}, f)
html = "<article><video src='v.mp4'></video><p>Texto.</p></article>"
mock_resp = (200, json.dumps({"message": {"content": json.dumps({"content_type": "media", "media_type": "video"})}}))
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler, \
patch("scripts.extract_article_contents._http_post_json", return_value=mock_resp):
MockCrawler.return_value = mock_crawl_html(html)
process_batch(input_file, silent=True)
captured = capsys.readouterr()
assert "[MEDIA]" not in captured.err
def test_api_key_security_not_leaked_in_logs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Valida que chaves secretas de API (Groq/OmniRoute) nunca são expostas em logs/stderr."""
secret_groq = "gsk_secret_token_12345"
secret_omni = "omni_secret_token_67890"
monkeypatch.setenv("GROQ_API_KEY", secret_groq)
monkeypatch.setenv("OMNIROUTE_API_KEY", secret_omni)
monkeypatch.setenv("OMNIROUTE_ENDPOINT", "http://omniroute.test")
input_file = tmp_path / "sec_test.json"
with open(input_file, "w", encoding="utf-8") as f:
json.dump({"items": [{"titulo": "T", "url": "https://example.com/sec"}]}, f)
html = "<article><video src='v.mp4'></video><p>Texto de teste confidencial.</p></article>"
# Ollama falha -> Groq falha -> OmniRoute responde
def mock_http(url: str, payload: dict, headers: dict, timeout: int) -> tuple[int, str]:
if "11434" in url:
raise ConnectionRefusedError()
if "groq.com" in url:
raise RuntimeError("Groq rate limit")
return 200, json.dumps({"choices": [{"message": {"content": json.dumps({"content_type": "media", "media_type": "mixed"})}}]})
with patch("scripts.extract_article_contents.ArticleCrawler") as MockCrawler, \
patch("scripts.extract_article_contents._http_post_json", side_effect=mock_http):
MockCrawler.return_value = mock_crawl_html(html)
process_batch(input_file, silent=False)
captured = capsys.readouterr()
assert secret_groq not in captured.err
assert secret_omni not in captured.err
assert secret_groq not in captured.out
assert secret_omni not in captured.out
# Garante que payload textual não foi vazado no log
assert "Texto de teste confidencial." not in captured.err
def test_cli_argument_compatibility() -> None:
"""Valida que argumentos de linha de comando existentes continuam totalmente compatíveis."""
from scripts.extract_article_contents import parse_arguments
args = parse_arguments(["-i", "input.json", "-o", "custom_out.json", "--limit", "10", "--lang", "es", "--silent"])
assert args.input == "input.json"
assert args.output == "custom_out.json"
assert args.limit == 10
assert args.language == "es"
assert args.silent is True
+21 -13
View File
@@ -27,9 +27,15 @@ import yaml
SCOPED_PYTHON_PATHS = [
"src/runtime",
"tests/runtime",
"scripts/extract_article_contents.py",
"tests/unit",
"tests/integration",
]
CONTRACT_SCHEMA_DIR = "specs/006-article-consolidation-runtime/contracts"
CONTRACT_SCHEMA_DIRS = [
"specs/006-article-consolidation-runtime/contracts",
"specs/007-media-article-routing/contracts",
]
PROMPTFOO_CONFIG_PATHS = [
"evals/promptfoo.config.yaml",
]
@@ -90,10 +96,11 @@ class RegexASTVisitor(ast.NodeVisitor):
def check_python_ast(root: Path) -> List[str]:
violations: List[str] = []
for scoped_rel in SCOPED_PYTHON_PATHS:
target_dir = root / scoped_rel
if not target_dir.exists():
target_path = root / scoped_rel
if not target_path.exists():
continue
for py_file in target_dir.rglob("*.py"):
py_files = [target_path] if target_path.is_file() else list(target_path.rglob("*.py"))
for py_file in py_files:
try:
content = py_file.read_text(encoding="utf-8")
tree = ast.parse(content, filename=str(py_file))
@@ -107,16 +114,17 @@ def check_python_ast(root: Path) -> List[str]:
def check_json_schemas(root: Path) -> List[str]:
violations: List[str] = []
schema_dir = root / CONTRACT_SCHEMA_DIR
if not schema_dir.exists():
return violations
for schema_rel in CONTRACT_SCHEMA_DIRS:
schema_dir = root / schema_rel
if not schema_dir.exists():
continue
for schema_file in schema_dir.glob("*.schema.json"):
try:
data = json.loads(schema_file.read_text(encoding="utf-8"))
_find_json_pattern_keys(data, str(schema_file), violations)
except Exception as e:
violations.append(f"{schema_file} - Failed to parse JSON: {e}")
for schema_file in schema_dir.glob("*.schema.json"):
try:
data = json.loads(schema_file.read_text(encoding="utf-8"))
_find_json_pattern_keys(data, str(schema_file), violations)
except Exception as e:
violations.append(f"{schema_file} - Failed to parse JSON: {e}")
return violations
@@ -379,6 +379,9 @@ def test_funnel_live_api_execution_if_configured():
content = "O River Plate empatou em 1 a 1 em Bogotá com gols de Otamendi na Copa Sul-Americana."
refined = adapter.disambiguate(ecp, content, initial_res)
if refined is None and initial_res.warnings:
pytest.skip(f"Live API unavailable or blocked: {initial_res.warnings}")
assert refined is not None
assert refined.decision in [
DecisionCategory.DIRECT_INHERENT,
+18 -8
View File
@@ -18,6 +18,7 @@ from scripts.extract_article_contents import (
ExtractedArticle,
ExtractionBatchReport,
InputArticle,
MediaClassification,
NewspaperData,
NewspaperExtractor,
ReadabilityData,
@@ -321,13 +322,17 @@ def test_e2e_live_article_extraction(tmp_path: Path):
out_file = tmp_path / "e2e_live_extracted.json"
# Executa o batch real com limite de 1 notícia
report = process_batch(
input_path=live_input_file,
output_path=out_file,
limit=1,
silent=True,
)
# Executa o batch real com limite de 1 notícia simulando classificação textual
with patch(
"scripts.extract_article_contents.classify_media_content",
return_value=(MediaClassification(content_type="text", media_type=None), None),
):
report = process_batch(
input_path=live_input_file,
output_path=out_file,
limit=1,
silent=True,
)
assert report.total_articles == 1
assert report.successful_articles == 1
@@ -364,9 +369,14 @@ def test_e2e_cli_live_execution(tmp_path: Path):
pytest.skip("Arquivo out/river_plate.json não encontrado para teste E2E.")
out_file = tmp_path / "e2e_cli_live.json"
exit_code = main(["-i", str(live_input_file), "-o", str(out_file), "--limit", "1", "-s"])
with patch(
"scripts.extract_article_contents.classify_media_content",
return_value=(MediaClassification(content_type="text", media_type=None), None),
):
exit_code = main(["-i", str(live_input_file), "-o", str(out_file), "--limit", "1", "-s"])
assert exit_code == 0
assert out_file.exists()
data = json.loads(out_file.read_text(encoding="utf-8"))
assert data["successful_articles"] == 1
+365
View File
@@ -0,0 +1,365 @@
"""
Testes unitários do classificador de mídia e gate estrutural DOM.
Valida schema de 2 campos, navegação estrutural na DOM (Zero-Regex)
e montagem do payload compacto.
"""
from __future__ import annotations
import json
import sys
import urllib.error
from pathlib import Path
import pytest
from bs4 import BeautifulSoup
# Adiciona o diretório raiz ao sys.path para import dos scripts
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from scripts.extract_article_contents import (
MEDIA_CLASSIFIER_PROMPT,
MEDIA_CLASSIFIER_SCHEMA,
MediaCandidateInfo,
MediaClassification,
build_compact_payload,
classify_media_content,
detect_candidate_media,
validate_classifier_response,
)
# ==============================================================================
# 1. Testes de Validação do Contrato Estruturado (validate_classifier_response)
# ==============================================================================
def test_validate_classifier_response_text_valid() -> None:
data = {"content_type": "text", "media_type": None}
result = validate_classifier_response(data)
assert result == MediaClassification(content_type="text", media_type=None)
def test_validate_classifier_response_text_with_media_type_invalid() -> None:
data = {"content_type": "text", "media_type": "image"}
assert validate_classifier_response(data) is None
def test_validate_classifier_response_media_valid() -> None:
for m_type in ("video", "image", "images", "embed", "mixed"):
data = {"content_type": "media", "media_type": m_type}
result = validate_classifier_response(data)
assert result == MediaClassification(content_type="media", media_type=m_type)
def test_validate_classifier_response_media_with_null_invalid() -> None:
data = {"content_type": "media", "media_type": None}
assert validate_classifier_response(data) is None
def test_validate_classifier_response_unknown_values() -> None:
assert validate_classifier_response({"content_type": "audio", "media_type": None}) is None
assert validate_classifier_response({"content_type": "media", "media_type": "podcast"}) is None
def test_validate_classifier_response_missing_or_extra_fields() -> None:
assert validate_classifier_response({"content_type": "text"}) is None
assert validate_classifier_response({"content_type": "text", "media_type": None, "extra": 123}) is None
assert validate_classifier_response("not_a_dict") is None
# ==============================================================================
# 2. Testes do Gate Estrutural DOM (detect_candidate_media)
# ==============================================================================
def test_detect_candidate_media_video() -> None:
html = """
<html>
<body>
<article>
<h1>Título com Vídeo</h1>
<video controls><source src="movie.mp4" type="video/mp4"></video>
<p>Texto curto da matéria.</p>
</article>
</body>
</html>
"""
soup = BeautifulSoup(html, "html.parser")
info = detect_candidate_media(soup)
assert info.has_candidate_media is True
assert info.has_video is True
assert info.image_count == 0
assert info.has_embed is False
def test_detect_candidate_media_isolated_source_not_video() -> None:
html = """
<html>
<body>
<article>
<h1>Artigo com source isolado</h1>
<audio><source src="podcast.mp3"></audio>
<p>Apenas texto e áudio.</p>
</article>
</body>
</html>
"""
soup = BeautifulSoup(html, "html.parser")
info = detect_candidate_media(soup)
assert info.has_video is False
assert info.has_candidate_media is False
def test_detect_candidate_media_image_wrappers_single_count() -> None:
html = """
<html>
<body>
<article>
<h1>Notícia com Imagens em Wrappers</h1>
<figure>
<img src="foto1.jpg" alt="Foto 1">
<figcaption>Legenda 1</figcaption>
</figure>
<picture>
<source srcset="foto2.webp">
<img src="foto2.jpg" alt="Foto 2">
</picture>
</article>
</body>
</html>
"""
soup = BeautifulSoup(html, "html.parser")
info = detect_candidate_media(soup)
assert info.has_candidate_media is True
assert info.image_count == 2
assert info.has_video is False
def test_detect_candidate_media_multiple_images() -> None:
html = """
<html>
<body>
<main>
<h1>Galeria de Fotos</h1>
<img src="img1.jpg">
<img src="img2.jpg">
<img src="img3.jpg">
<p>Galeria completa do evento.</p>
</main>
</body>
</html>
"""
soup = BeautifulSoup(html, "html.parser")
info = detect_candidate_media(soup)
assert info.has_candidate_media is True
assert info.image_count == 3
def test_detect_candidate_media_embeds() -> None:
html = """
<html>
<body>
<div role="main">
<h1>Post Incorporado</h1>
<iframe src="https://platform.twitter.com/embed/Tweet.html"></iframe>
<p>Veja o post acima.</p>
</div>
</body>
</html>
"""
soup = BeautifulSoup(html, "html.parser")
info = detect_candidate_media(soup)
assert info.has_candidate_media is True
assert info.has_embed is True
def test_detect_candidate_media_in_page_furniture_ignored() -> None:
html = """
<html>
<body>
<header>
<img src="logo.png" alt="Logo">
<nav><img src="icon.png"></nav>
</header>
<article>
<h1>Matéria Textual Pura</h1>
<p>Texto jornalístico longo sem mídia interna no corpo.</p>
<p>Segundo parágrafo informativo.</p>
</article>
<aside>
<iframe src="banner.html"></iframe>
<img src="ad.jpg">
</aside>
<footer>
<img src="partner.png">
</footer>
</body>
</html>
"""
soup = BeautifulSoup(html, "html.parser")
info = detect_candidate_media(soup)
assert info.has_candidate_media is False
assert info.image_count == 0
assert info.has_video is False
assert info.has_embed is False
def test_detect_candidate_media_structural_region_precedence() -> None:
# <article> deve ter precedência sobre <main> e <body>
html = """
<html>
<body>
<main>
<img src="outside_article.jpg">
<article>
<h1>Dentro do Article</h1>
<video src="article_video.mp4"></video>
<p>Parágrafo do artigo.</p>
</article>
</main>
</body>
</html>
"""
soup = BeautifulSoup(html, "html.parser")
info = detect_candidate_media(soup)
assert info.has_candidate_media is True
assert info.has_video is True
assert info.image_count == 0
# ==============================================================================
# 3. Testes de Montagem do Compact Payload (build_compact_payload)
# ==============================================================================
def test_build_compact_payload_preserves_text_and_unicode() -> None:
html = """
<html>
<head><title>Título da Publicação — Jornal Exemplo</title></head>
<body>
<article>
<h1>Título da Publicação</h1>
<p>Primeiro parágrafo com acentuação: <em>ação, notícia & análise</em>.</p>
<p>Segundo parágrafo com texto em espanhol: ¿Cómo estás? Fútbol y pasión.</p>
<video src="clip.mp4"></video>
</article>
</body>
</html>
"""
soup = BeautifulSoup(html, "html.parser")
candidate_info = MediaCandidateInfo(has_candidate_media=True, has_video=True, image_count=0, has_embed=False)
payload = build_compact_payload(soup, candidate_info)
assert "Título da Publicação" in payload
assert "ação, notícia & análise" in payload
assert "¿Cómo estás? Fútbol y pasión." in payload
assert "Video=True" in payload
assert "ImagesCount=0" in payload
assert "Embed=False" in payload
assert "<p>" not in payload
assert "<article>" not in payload
# ==============================================================================
# 4. Testes de Bypass Direto do Gate Sem LLM (User Story 2 / T010)
# ==============================================================================
def test_detect_candidate_media_pure_text_bypasses_llm() -> None:
html = """
<html>
<body>
<article>
<h1>Editorial Textual Pura</h1>
<p>Primeiro parágrafo de notícia densa.</p>
<p>Segundo parágrafo detalhando o acontecimento político e econômico.</p>
<p>Terceiro parágrafo com as conclusões e declarações oficiais.</p>
</article>
</body>
</html>
"""
soup = BeautifulSoup(html, "html.parser")
info = detect_candidate_media(soup)
assert info.has_candidate_media is False
assert info.has_video is False
assert info.image_count == 0
assert info.has_embed is False
# ==============================================================================
# 5. Testes da Cadeia Sequencial de Provedores e Invariância de Prompt (User Story 3 / T013)
# ==============================================================================
def test_classify_media_content_first_valid_wins_ollama(monkeypatch: pytest.MonkeyPatch) -> None:
# Valida que prompt não solicita reasoning, keywords, summary, etc.
forbidden = ["reasoning", "confidence", "rationale", "summary", "keywords", "evidence", "tradução"]
for word in forbidden:
assert word not in MEDIA_CLASSIFIER_PROMPT.lower()
metrics = {"fallback_groq": 0, "fallback_omniroute": 0, "classification_failed": 0}
call_log: list[str] = []
def mock_http(url: str, payload: dict, headers: dict, timeout: int) -> tuple[int, str]:
call_log.append(url)
# Ollama responde válido
return 200, json.dumps({"message": {"content": json.dumps({"content_type": "media", "media_type": "video"})}})
monkeypatch.setattr("scripts.extract_article_contents._http_post_json", mock_http)
res, err = classify_media_content("payload", metrics, silent=True)
assert res == MediaClassification(content_type="media", media_type="video")
assert err is None
assert len(call_log) == 1
assert "11434" in call_log[0]
assert metrics["fallback_groq"] == 0
assert metrics["fallback_omniroute"] == 0
def test_classify_media_content_ollama_fail_groq_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GROQ_API_KEY", "test-key")
metrics = {"fallback_groq": 0, "fallback_omniroute": 0, "classification_failed": 0}
call_log: list[str] = []
def mock_http(url: str, payload: dict, headers: dict, timeout: int) -> tuple[int, str]:
call_log.append(url)
if "11434" in url:
# Ollama falha
raise urllib.error.URLError("Connection refused")
# Groq responde válido
assert payload["model"] == "openai/gpt-oss-20b"
assert payload["reasoning_effort"] == "low"
assert payload["temperature"] == 0.0
return 200, json.dumps({"choices": [{"message": {"content": json.dumps({"content_type": "text", "media_type": None})}}]})
monkeypatch.setattr("scripts.extract_article_contents._http_post_json", mock_http)
res, err = classify_media_content("payload", metrics, silent=True)
assert res == MediaClassification(content_type="text", media_type=None)
assert err is None
assert len(call_log) == 2
assert metrics["fallback_groq"] == 1
assert metrics["fallback_omniroute"] == 0
def test_classify_media_content_all_fail(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GROQ_API_KEY", "test-key")
monkeypatch.setenv("OMNIROUTE_ENDPOINT", "http://omniroute.local")
metrics = {"fallback_groq": 0, "fallback_omniroute": 0, "classification_failed": 0}
def mock_http(url: str, payload: dict, headers: dict, timeout: int) -> tuple[int, str]:
raise urllib.error.URLError("Server unreachable")
monkeypatch.setattr("scripts.extract_article_contents._http_post_json", mock_http)
res, err = classify_media_content("payload", metrics, silent=True)
assert res is None
assert err is not None
assert metrics["fallback_groq"] == 1
assert metrics["fallback_omniroute"] == 1