182 lines
16 KiB
Markdown
182 lines
16 KiB
Markdown
# Implementation Plan: Classificação e Roteamento de Notícias Predominantemente de Mídia
|
|
|
|
**Branch**: `007-media-article-routing` | **Date**: 2026-08-24 | **Spec**: [spec.md](spec.md)
|
|
|
|
**Input**: Feature specification from `/specs/007-media-article-routing/spec.md`
|
|
|
|
---
|
|
|
|
## 1. Summary
|
|
|
|
Esta feature adiciona ao script [`scripts/extract_article_contents.py`](../../scripts/extract_article_contents.py) a capacidade de identificar publicações cujo conteúdo informativo principal seja mídia (vídeo, imagem única, múltiplas imagens/galeria, embed ou mídia mista) e cujo texto atue apenas como introdução ou contextualização.
|
|
|
|
Após o crawl com Foxcape, a página sofre uma análise estrutural na DOM (zero-regex via BeautifulSoup). Se nenhuma mídia relevante for detectada, o artigo segue diretamente para o multimotor textual (`extract_all_engines`). Se houver mídia relevante, o texto editorial normalizado e o resumo estrutural são avaliados por uma cadeia sequencial de LLMs com fallback puramente operacional (Ollama/Qwen3.5 2B $\rightarrow$ Groq/openai/gpt-oss-20b $\rightarrow$ OmniRoute/cgpt-web/gpt-5.5). Artigos classificados como `media` são desviados antes dos três motores textuais e gravados no arquivo de mídia dedicado (`*_media.json`), enquanto artigos textuais e eventuais falhas de classificação continuam para o arquivo principal (`*_extracted.json`).
|
|
|
|
---
|
|
|
|
## 2. Technical Context
|
|
|
|
- **Linguagem & Tipagem**: Python `>=3.10` com anotações de tipo completas em conformidade com o princípio de qualidade do repositório (Constitution §Technical Constraints).
|
|
- **Dependências Reutilizadas**: `beautifulsoup4`, `trafilatura`, `newspaper4k`, `readability-lxml`, `foxcape` (todas já instaladas e ativas no projeto).
|
|
- **Cliente HTTP para LLMs**: Biblioteca padrão do Python (`urllib.request`, `urllib.error`, `json`), sem novas dependências externas.
|
|
- **Armazenamento / I/O**: Arquivos JSON no filesystem local (`*_extracted.json` e `*_media.json`).
|
|
- **Suíte de Testes**: `pytest` com simulação determinística dos 5 estados da cadeia de provedores; script estático existente [`tests/scripts/check_zero_regex.py`](../../tests/scripts/check_zero_regex.py).
|
|
- **Tipo de Projeto**: Pipeline de linha de comando (CLI) existente.
|
|
|
|
---
|
|
|
|
## 3. Constitution Check
|
|
|
|
| Princípio Constitucional | Avaliação Técnica & Rastreabilidade | Status |
|
|
|:---|:---|:---:|
|
|
| **I. Modularity & CLI-First** | Integrado diretamente em `scripts/extract_article_contents.py`, preservando 100% dos parâmetros CLI (`-i`, `-o`, `-l`, `--lang`, `-t`, `-s`) e exit codes (`0`, `1`, `2`, `130`). | **PASS** |
|
|
| **II. Determinism & Data Integrity** | Contrato estruturado de 2 campos no LLM com configuração determinística; parsing estrito de DOM; preservação intacta dos metadados de entrada (`input_meta`). | **PASS** |
|
|
| **III. Multi-Engine & Fault-Tolerant Fallback** | Artigos textuais continuam processados pelos 3 motores; cadeia sequencial com 2 níveis de contingência operacional (Ollama $\rightarrow$ Groq $\rightarrow$ OmniRoute); falha isolada por artigo. | **PASS** |
|
|
| **IV. Test-First & Empirical Validation** | Suíte de testes cobrindo cenários A a M e os 5 estados da cadeia de provedores na CI sem chamadas de rede externas; verificação estática Zero-Regex. | **PASS** |
|
|
| **V. Observability & Structured Logging** | Contabilização e emissão das 11 métricas operacionais obrigatórias no resumo/log stderr existente; logs de fallbacks e provedor sem expor segredos ou payload integral. | **PASS** |
|
|
|
|
---
|
|
|
|
## 4. Arquitetura e Decisões Técnicas Fechadas
|
|
|
|
### 4.1 Gate Estrutural na DOM (Zero-Regex)
|
|
1. **Localização da Região Editorial**:
|
|
- Inspecionar a DOM carregada buscando nós na seguinte ordem de precedência: `<article>`, `<main>`, `<div role="main">`, ou `<body>` caso nenhuma anterior exista.
|
|
- Descartar nós estruturais fora do conteúdo da matéria (`<header>`, `<nav>`, `<footer>`, `<aside>`).
|
|
- Sem regex, sem heurísticas de classes CSS, sem seletores específicos de sites e sem detector de anúncios.
|
|
2. **Critério de Mídia Candidata Relevante**:
|
|
- **Vídeo**: tags `<video>` $\rightarrow$ `has_video = True`. Tags `<source>` pertencentes a `<video>` não são contadas isoladamente.
|
|
- **Imagem**: contagem das tags `<img>` reais na região editorial. Wrappers como `<figure>` e `<picture>` não incrementam ou duplicam o contador.
|
|
- **Embed**: tags `<iframe>`, `<embed>`, `<object>` $\rightarrow$ `has_embed = True`. Sem listas de domínios externos.
|
|
- **Múltiplas Imagens**: contagem $\ge 2$ de tags `<img>` na região editorial.
|
|
3. **Decisão do Gate**:
|
|
- Retorna `MediaCandidateInfo(has_candidate_media, has_video, image_count, has_embed)`.
|
|
- Se `has_candidate_media == False` $\rightarrow$ desvio direto para `extract_all_engines()` (zero chamadas LLM).
|
|
- Se `has_candidate_media == True` $\rightarrow$ montagem de payload e execução da cadeia LLM.
|
|
|
|
### 4.2 Payload Compacto do Classificador
|
|
- **Campos Extraídos**:
|
|
- `title`: Título obtido da tag `<title>` ou `<h1>` da matéria.
|
|
- `text_content`: Textos dos parágrafos (`<p>`) da região editorial, normalizados sem tags HTML e preservando o conteúdo jornalístico substancial da matéria (sem truncamento arbitrário de caracteres).
|
|
- `media_summary`: Resumo dos elementos de mídia identificados no gate (`has_video`, `image_count`, `has_embed`).
|
|
- **Prompt Único Multilíngue**: Instrução concisa solicitando estritamente a classificação em `content_type` (`text` ou `media`) e `media_type` (`video`, `image`, `images`, `embed`, `mixed` ou `null`), sem reasoning deliberativo, sem tradução e sem resumos.
|
|
|
|
### 4.3 Cadeia Sequencial de Provedores, Structured Output e Controle de Reasoning
|
|
1. **Configuração dos Provedores e Reasoning**:
|
|
- **Ollama (Primário)**:
|
|
- Endpoint: `os.environ.get("OLLAMA_ENDPOINT", "http://localhost:11434")`
|
|
- Modelo: `os.environ.get("OLLAMA_MODEL", "qwen3.5:2b")`
|
|
- Timeout: `int(os.environ.get("OLLAMA_TIMEOUT", "10"))`
|
|
- Structured Output & Reasoning: passa o JSON Schema diretamente no campo `format` da requisição `/api/chat`, com `options: {"temperature": 0.0}` e `think: false` para desabilitar explicitamente thinking no Qwen3.5 2B.
|
|
- **Groq (1º Fallback)**:
|
|
- Endpoint: `os.environ.get("GROQ_ENDPOINT", "https://api.groq.com/openai/v1/chat/completions")`
|
|
- API Key: `os.environ.get("GROQ_API_KEY")`
|
|
- Modelo: `os.environ.get("GROQ_MODEL", "openai/gpt-oss-20b")`
|
|
- Timeout: `int(os.environ.get("GROQ_TIMEOUT", "15"))`
|
|
- Structured Output & Reasoning: `response_format={"type": "json_schema", "json_schema": {"name": "media_classifier", "strict": True, "schema": <schema>}}`, `temperature: 0.0` e `reasoning_effort: "low"`.
|
|
- **OmniRoute (2º Fallback)**:
|
|
- Endpoint: `os.environ.get("OMNIROUTE_ENDPOINT")` (configuração externa obrigatória sem default inventado)
|
|
- API Key: `os.environ.get("OMNIROUTE_API_KEY")`
|
|
- Modelo: `os.environ.get("OMNIROUTE_MODEL", "cgpt-web/gpt-5.5")`
|
|
- Timeout: `int(os.environ.get("OMNIROUTE_TIMEOUT", "20"))`
|
|
- Structured Output & Reasoning: `response_format` com JSON Schema compatível com a instalação OpenAI-compatible utilizada, `temperature: 0.0` quando suportado, sem parâmetro inventado de reasoning.
|
|
2. **Regra de Transição e Validação de Schema**:
|
|
- A aplicação sempre valida os dois campos recebidos:
|
|
- `content_type` $\in$ `{"text", "media"}`;
|
|
- se `content_type == "text"`, `media_type` deve ser `None`;
|
|
- se `content_type == "media"`, `media_type` deve ser um de `{"video", "image", "images", "embed", "mixed"}`.
|
|
- Resposta válida $\rightarrow$ encerra a cadeia imediatamente (primeira resposta válida conclui).
|
|
- Falha operacional (timeout, conexão recusada, erro HTTP ou schema incompatível) $\rightarrow$ avança imediatamente para o próximo provedor na ordem estrita Ollama $\rightarrow$ Groq $\rightarrow$ OmniRoute.
|
|
- Sem retries por provedor, sem exponential backoff, sem circuit breakers e sem discovery dinâmico.
|
|
- Se todos os 3 provedores falharem $\rightarrow$ artigo recebe `classification_status = "failed"` e `error_message`, sendo gravado inline no JSON principal sem multimotor e sem interromper o lote.
|
|
|
|
### 4.4 I/O, Nomenclatura de Arquivos e Contadores
|
|
- **Regras de Resolução de Caminhos**:
|
|
- Sem `-o`: entrada `out/river_plate.json` $\rightarrow$ textual `out/river_plate_extracted.json`, mídia `out/river_plate_media.json`.
|
|
- Com `-o out/dir/saida.json` $\rightarrow$ textual `out/dir/saida.json`, mídia `out/dir/saida_media.json`.
|
|
- Nenhuma nova flag CLI (sem `--media-output`).
|
|
- **Arquivo de Mídia (`*_media.json`)**:
|
|
- O arquivo `*_media.json` MUST ser gerado em toda execução bem-sucedida do lote.
|
|
- Envelope contendo estritamente `{ "articles": [ MediaArticle... ] }`.
|
|
- Quando nenhum artigo for classificado como mídia no lote, o arquivo conterá exatamente `{"articles": []}` (sem omissão condicional).
|
|
- **Arquivo Textual Principal (`*_extracted.json`)**:
|
|
Envelope `ExtractionBatchReport` onde os contadores refletem estritamente os registros presentes no arquivo:
|
|
- `total_articles`: contagem de registros presentes no array `articles`;
|
|
- `successful_articles`: artigos textuais processados com sucesso pelo multimotor;
|
|
- `failed_articles`: registros de falha presentes (falhas de crawl ou com `classification_status = "failed"`).
|
|
- **Preservação de Metadados (`input_meta`)**:
|
|
O dicionário de metadados da entrada é preservado integralmente em `input_meta` para todos os registros (textuais, mídia e falhas), exigindo `titulo` e `url`, sem inventar valores default e sem descartar campos adicionais.
|
|
|
|
### 4.5 Observabilidade, Logging e Pontos Exatos de Tracking das 11 Métricas
|
|
As 11 métricas são contabilizadas em memória (dicionário local dentro de `process_batch`) e registradas nos seguintes pontos exatos:
|
|
1. `total_evaluated`: incrementado quando um artigo com crawl bem-sucedido entra no gate estrutural da DOM;
|
|
2. `text`: incrementado quando: (a) não existe mídia candidata no gate (bypass direto), OU (b) LLM retorna `content_type="text"`;
|
|
3. `media`: incrementado exclusivamente quando o LLM retorna `content_type="media"`;
|
|
4. `media/video`: incrementado em conjunto com `media` quando `media_type == "video"`;
|
|
5. `media/image`: incrementado em conjunto com `media` quando `media_type == "image"`;
|
|
6. `media/images`: incrementado em conjunto com `media` quando `media_type == "images"`;
|
|
7. `media/embed`: incrementado em conjunto com `media` quando `media_type == "embed"`;
|
|
8. `media/mixed`: incrementado em conjunto com `media` quando `media_type == "mixed"`;
|
|
9. `fallback_groq`: incrementado imediatamente antes de despachar a chamada HTTP para o Groq;
|
|
10. `fallback_omniroute`: incrementado imediatamente antes de despachar a chamada HTTP para o OmniRoute;
|
|
11. `classification_failed`: incrementado uma única vez por artigo quando os 3 provedores falharem cumulativamente.
|
|
|
|
- **Logging no stderr**: Registro de provider utilizado, fallbacks acionados, classificação e falhas. Respeita a flag `-s/--silent`. Segredos e payloads textuais integrais nunca são logados por padrão.
|
|
|
|
---
|
|
|
|
## 5. Organização de Arquivos (Mínima e Sem Classes Desnecessárias)
|
|
|
|
### Funções Implementadas em `scripts/extract_article_contents.py`
|
|
Para manter o mínimo de código e evitar classes desnecessárias:
|
|
- `detect_candidate_media(soup: BeautifulSoup) -> MediaCandidateInfo`: Função pura de inspeção estrutural na DOM (sem regex).
|
|
- `build_compact_payload(soup: BeautifulSoup, candidate_info: MediaCandidateInfo) -> str`: Função pura de extração e normalização do texto editorial e resumo estrutural.
|
|
- `classify_media_content(payload: str) -> tuple[MediaClassification | None, str | None]`: Função que orquestra a chamada sequencial aos 3 provedores via `urllib.request` e validação estrita.
|
|
- `save_media_json(articles: list[dict[str, Any]], output_path: Path) -> None`: Gravação do arquivo de mídia com a mesma estratégia de escrita JSON do script existente.
|
|
- Atualização do loop `process_batch` para incorporar o desvio, gravação de `*_media.json` e contabilidade das 11 métricas.
|
|
|
|
*(Nota de Reutilização: O módulo `src/tools/adapters/llm.py` foi inspecionado; ele é altamente especializado na desambiguação de entidades ECP da feature 001 com classes acopladas, de modo que a integração via funções diretas com `urllib.request` em `extract_article_contents.py` é a solução mais desacoplada, limpa e com menor diff).*
|
|
|
|
### Arquivo Existente Reutilizado
|
|
- [`tests/scripts/check_zero_regex.py`](../../tests/scripts/check_zero_regex.py):
|
|
- Inclusão dos novos arquivos de teste no escopo de validação estática.
|
|
|
|
### Novos Arquivos de Teste
|
|
- `tests/unit/test_media_classifier.py`:
|
|
- Testes do gate DOM (vídeo sem falso positivo de source, contagem correta de imagens sem duplicar figure/picture, embeds);
|
|
- Testes de montagem do payload com texto completo;
|
|
- Testes de validação de schema e simulação dos 5 estados da cadeia de provedores.
|
|
- `tests/integration/test_media_routing.py`:
|
|
- Testes de integração em lote cobrindo cenários A a M;
|
|
- Validação de caminhos `-o`, contadores, geração incondicional de `*_media.json` (com `[]` quando vazio) e persistência de falha inline.
|
|
|
|
---
|
|
|
|
## 6. Matriz de Rastreabilidade (Requisitos $\rightarrow$ Código $\rightarrow$ Testes)
|
|
|
|
| Requisito | Descrição | Implementação em `extract_article_contents.py` | Teste Correspondente |
|
|
|:---|:---|:---|:---|
|
|
| **FR-001 - FR-004** | Análise estrutural da DOM e desvio sem LLM | `detect_candidate_media()` | `test_media_classifier.py::test_structural_gate_*` |
|
|
| **FR-005 - FR-006** | Payload compacto e proibições de mídia binária | `build_compact_payload()` | `test_media_classifier.py::test_compact_payload_*` |
|
|
| **FR-007 - FR-011** | Contrato estruturado de 2 campos e Structured Output | `classify_media_content()` | `test_media_classifier.py::test_schema_validation_*` |
|
|
| **FR-012 - FR-017** | Roteamento textual/mídia, `*_media.json` e contadores | `process_batch()`, `save_media_json()` | `test_media_routing.py::test_routing_and_counters_*` |
|
|
| **FR-018 - FR-024** | Cadeia sequencial Ollama $\rightarrow$ Groq $\rightarrow$ OmniRoute e falha total | `classify_media_content()` | `test_media_classifier.py::test_provider_chain_*` |
|
|
| **FR-025 - FR-026** | Configuração externa e mascaramento de segredos | `classify_media_content()` | `test_media_routing.py::test_security_secrets_masked` |
|
|
| **FR-027 - FR-028** | 11 métricas operacionais e logging | `process_batch()` | `test_media_routing.py::test_metrics_logging` |
|
|
| **FR-029 - FR-030** | Suporte multilíngue e compatibilidade CLI | `parse_arguments()`, `process_batch()` | `test_media_routing.py::test_cli_compatibility` |
|
|
| **SC-007** | Zero-Regex em toda a nova implementação | AST Checker | `tests/scripts/check_zero_regex.py` |
|
|
|
|
---
|
|
|
|
## 7. Invariantes e Limites de Escopo
|
|
|
|
Fica expressamente estabelecido que a implementação **NÃO DEVE** introduzir:
|
|
- Bancos de dados, filas de mensagens, DLQ ou novos workers;
|
|
- Novos serviços, microserviços ou processos autônomos;
|
|
- Pipelines adicionais de NLP ou frameworks de agentes (LangChain, LangGraph, LLM-as-a-judge);
|
|
- Votação, consenso entre modelos ou fallbacks por incerteza subjetiva;
|
|
- Download de mídia binária, OCR, visão computacional ou transcrição de áudio/vídeo;
|
|
- Interação com carousels ou chamadas a APIs de redes sociais;
|
|
- Alterações no comportamento de carregamento do Foxcape ou na lógica interna de Trafilatura, Newspaper4k e Readability;
|
|
- Criação de novas flags CLI como `--media-output`, novos arquivos como `*_failed.json` ou classes desnecessárias como `MediaBatchReport` e `MediaMetricsCollector`.
|