feat(media-routing): implement 007 media article routing, runtime architecture diagram and update graphify knowledge graph
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user