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,98 @@
# Media Routing Requirements Quality & Implementation Planning Checklist
**Purpose**: Validate requirements quality, architectural fidelity, and implementation plan completeness for the media routing feature
**Created**: 2026-08-24
**Feature**: [spec.md](../spec.md) | [plan.md](../plan.md) | [research.md](../research.md) | [data-model.md](../data-model.md)
**Note**: This checklist is a reviewer-owned review artifact. Mark an item `[x]` only when the reviewer determines the quality criterion is satisfied.
**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied. It does not mean implementation work is complete.
---
## 1. Requirement & Plan Fidelity
- [x] CHK001 Does the specification define behavioral requirements for structural media relevance to the publication while the plan and research define the concrete, minimal DOM parsing algorithm without contradiction? [Fidelity, Spec §FR-001, §FR-004; Plan §4.1; Research §Decisão 1]
- [x] CHK002 Are all 5 media subtypes (`video`, `image`, `images`, `embed`, `mixed`) exhaustively specified with their definitions? [Completeness, Spec §FR-009]
- [x] CHK003 Are the payload fields for compact LLM input generation fully defined across the plan and data model without arbitrary truncation of editorial text? [Completeness, Plan §4.2; Data-Model §2]
- [x] CHK004 Are the required fields, mandatory file generation in all successful batch runs, and minimal envelope structure (`articles`, containing `[]` when zero media articles) for `*_media.json` explicitly specified without extra report fields? [Completeness, Spec §FR-014, §FR-015; Plan §4.4; Contract: media-output.schema.json]
- [x] CHK005 Are all 11 mandatory operational metrics enumerated with their exact tracking points documented in the plan? [Completeness, Spec §FR-028; Plan §4.5]
- [x] CHK006 Are the specific conditions that trigger operational fallback defined for each provider? [Completeness, Spec §FR-019, §FR-020; Plan §4.3]
- [x] CHK007 Are error handling and reporting requirements specified when all 3 LLM providers fail, recording the failure inline in the main JSON with `classification_status: "failed"` without creating separate failure files or DTOs? [Completeness, Spec §FR-022; Plan §4.3; Data-Model §2.6]
- [x] CHK008 Are all ungrounded technical promises, invented latency SLOs (<1.5s, <10ms) and artificial benchmarks completely absent from the plan? [Fidelity, Plan §2, §4]
---
## 2. Minimal Implementation & Code Reusability
- [x] CHK009 Were real repository files inspected, reusing existing dependencies (`beautifulsoup4`, `urllib.request`) and avoiding new package installations? [Minimalism, Plan §2, §5]
- [x] CHK010 Is the implementation organized as lightweight functions in `scripts/extract_article_contents.py` rather than unnecessary class hierarchies or generic provider frameworks? [Minimalism, Plan §5]
- [x] CHK011 Are speculative abstractions, generic provider frameworks, and unnecessary domain DTOs (e.g. `MediaBatchReport`, `MediaMetricsCollector`, `FailedArticle`, `ClassificationFailedArticle`) completely absent? [Minimalism, Plan §5, §7]
- [x] CHK012 Was `src/tools/adapters/llm.py` evaluated and its non-reuse properly justified due to its coupling to the ECP domain rather than creating redundant provider frameworks? [Minimalism, Plan §5; Research §Decisão 3]
---
## 3. Structural DOM Gate
- [x] CHK013 Is the DOM structural gate defined without treating `<source>` alone as video, without double-counting `<figure>/<picture>` wrappers, and distinguishing editorial content from page structure (`<header>`, `<nav>`, `<footer>`, `<aside>`)? [Gate, Plan §4.1; Research §Decisão 1]
- [x] CHK014 Is the structural gate completely free of regular expressions (`re`), language-specific keywords, site-specific selectors, and ad detectors? [Constraint, Spec §FR-002; Plan §4.1]
- [x] CHK015 Does the gate bypass the LLM completely when no relevant candidate media is present, sending the article directly to `extract_all_engines()`? [Gate, Spec §FR-003; Plan §4.1]
---
## 4. Classifier Payload & Interface Contracts
- [x] CHK016 Is the compact payload defined with title, normalized editorial text without HTML and without arbitrary character truncation, and structural media summary? [Payload, Spec §FR-005, §FR-006; Plan §4.2]
- [x] CHK017 Does the classifier output contract contain exactly 2 fields (`content_type` and `media_type`), enforcing `text -> media_type = null` and `media -> media_type != null` in application validation? [Contract, Spec §FR-007, §FR-008, §FR-009; Contract: classifier-io.schema.json]
- [x] CHK018 Is the prompt unique, concise, and language-independent, explicitly prohibiting reasoning, summaries, rationale, and translation requests? [Prompt, Spec §FR-010; Plan §4.2]
- [x] CHK019 Does Ollama use JSON Schema in `format`, Groq use native JSON Schema Structured Output with `openai/gpt-oss-20b`, OmniRoute use JSON Schema Structured Output, and the application strictly validate the two-field schema? [Contract, Spec §FR-011; Plan §4.3]
- [x] CHK020 Is deliberative reasoning/thinking explicitly disabled/configured per provider (Ollama: `think=false`, Groq: `reasoning_effort="low"`, OmniRoute: sem parâmetro inventado), ensuring no reasoning content appears in the output? [Reasoning, Plan §4.3; Research §Decisão 3]
---
## 5. Sequential Provider Chain & Fallback Rules
- [x] CHK021 Is the provider order strictly Ollama (`qwen3.5:2b`) → Groq (`openai/gpt-oss-20b`) → OmniRoute (`cgpt-web/gpt-5.5`), executing sequentially and stopping immediately on the first valid response? [Providers, Spec §FR-018, §FR-019, §FR-020, §FR-021; Plan §4.3]
- [x] CHK022 Are voting, model consensus, second opinions, LLM-as-a-judge, and confidence thresholds strictly excluded? [Boundary, Spec §FR-021; Plan §4.3, §7]
- [x] CHK023 Are automatic per-provider retries, exponential backoff, circuit breakers, discovery frameworks, and health services strictly excluded from the provider chain? [Boundary, Plan §4.3, §7]
- [x] CHK024 Are endpoints, models, timeouts, and API keys externalized via environment variables, with OmniRoute having no invented default hostname? [Security, Spec §FR-025; Plan §4.3]
---
## 6. I/O Persistence, Counter Semantics & CLI Interface
- [x] CHK025 Are the naming and path derivation rules for `*_media.json` explicit and identical for default (same stem/dir) and custom `-o/--output` paths? [I/O, Spec §FR-014; Plan §4.4; Contract: cli-interface.md]
- [x] CHK026 Does every successful batch run generate `*_media.json`, using `{ "articles": [] }` when zero media articles were classified, without extra report counters or status aggregates? [I/O, Spec §FR-014, §FR-015; Plan §4.4; Contract: media-output.schema.json]
- [x] CHK027 Do the main JSON counters (`total_articles`, `successful_articles`, `failed_articles`) reflect strictly the items present in that file, excluding media articles and including classification failures? [Counters, Spec §FR-017; Plan §4.4]
- [x] CHK028 Does the implementation preserve 100% of the input metadata dictionary in `input_meta` across text, media, and failure records without dropping unknown fields or inventing default values? [Data Integrity, Plan §4.4; Data-Model §2.1]
- [x] CHK029 Does the implementation preserve 100% of the existing CLI interface (`-i`, `-o`, `-l`, `--lang`, `-t`, `-s`) without creating new flags like `--media-output`? [CLI, Spec §FR-030; Plan §4.4]
---
## 7. Observability, Logging & Security Constraints
- [x] CHK030 Are all 11 required operational metrics incremented in memory at the exact points defined and emitted via existing stderr logging mechanisms while respecting the `-s/--silent` flag? [Observability, Spec §FR-028; Plan §4.5]
- [x] CHK031 Are logs structured to record provider usage, fallback transitions, final classifications, and total failures without logging full text payloads by default? [Observability, Spec §FR-027; Plan §4.5]
- [x] CHK032 Are API keys, tokens, and credentials strictly prevented from appearing in logs, error messages, and JSON outputs? [Security, Spec §FR-026; Plan §4.5]
---
## 8. Test Coverage & CI Determinism
- [x] CHK033 Are all 13 normative test scenarios (A through M) plus zero-media batch generation covered in the test plan, spanning text-only, single media, multiple images, mixed media, long text with media, fallbacks, and multilingual cases? [Test Coverage, Spec §User Stories; Plan §6]
- [x] CHK034 Are deterministic unit and integration tests defined to simulate the 5 provider states in CI without requiring live network calls to Ollama, Groq, or OmniRoute? [Test CI, Spec §FR-024; Plan §4.3, §6]
- [x] CHK035 Is the zero-regex policy verified via static AST inspection in `tests/scripts/check_zero_regex.py` across all feature modules and tests? [Zero-Regex, Spec §SC-007; Plan §5, §6]
---
## 9. Global Artifact Consistency Check
- [x] CHK036 Are `spec.md`, `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `classifier-io.schema.json`, `media-output.schema.json`, and `cli-interface.md` 100% consistent with each other, confirming that classification failures are recorded inline with `classification_status: "failed"` and without any reintroduction of `MediaBatchReport`, `MediaMetricsCollector`, or DTOs de falha? [Consistency, Plan §5, §7; Data-Model §2]
---
## Notes
- Mark items `[x]` only after review confirms the quality criterion is satisfied
- Leave items unchecked when they still require clarification, correction, or reviewer evaluation
- `/speckit-implement` reads checklist checkbox state as a gate and must not modify markers
- Items are numbered sequentially (CHK001–CHK036) for easy reference
@@ -0,0 +1,34 @@
# Specification Quality Checklist: Classificação e Roteamento de Notícias Predominantemente de Mídia
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-08-24
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items passed specification validation. Ready for planning phase (`/speckit-plan`).
@@ -0,0 +1,37 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"name": "media_classifier",
"title": "MediaClassifierOutput",
"description": "Contrato estruturado estrito de dois campos emitido pelo classificador semântico",
"type": "object",
"required": [
"content_type",
"media_type"
],
"additionalProperties": false,
"properties": {
"content_type": {
"type": "string",
"enum": [
"text",
"media"
],
"description": "Classificação da publicação: text para texto jornalístico substancial, media para conteúdo predominantemente de mídia"
},
"media_type": {
"type": [
"string",
"null"
],
"enum": [
"video",
"image",
"images",
"embed",
"mixed",
null
],
"description": "Subtipo de mídia quando content_type=media, ou null quando content_type=text"
}
}
}
@@ -0,0 +1,123 @@
# CLI Contract & Routing Specification: `scripts/extract_article_contents.py`
## 1. Interface de Linha de Comando (CLI)
O script `scripts/extract_article_contents.py` preserva 100% da interface CLI existente sem novos argumentos:
```bash
python scripts/extract_article_contents.py \
-i, --input PATH # (Obrigatório) JSON de busca de notícias de entrada
[-o, --output PATH] # (Opcional) Caminho do JSON de saída textual
[-l, --limit N] # (Opcional) Limite máximo de artigos a processar
[--lang, --language CODE] # (Opcional) Código do idioma para NLP (ex: pt, es, en)
[-t, --timeout SEC] # (Opcional) Timeout em segundos para navegação (default: 30)
[-s, --silent] # (Opcional) Suprime logs no stderr
```
---
## 2. Regras de Resolução de Arquivos de Saída
### Caso A: Sem `-o/--output` (Padrão)
Para uma entrada: `out/river_plate.json`
- **Saída Textual**: `out/river_plate_extracted.json`
- **Saída de Mídia**: `out/river_plate_media.json` (gerado obrigatoriamente; contém `{"articles": []}` se nenhuma mídia for identificada)
### Caso B: Com `-o/--output` customizado
Para uma entrada: `out/noticias.json` com `-o out/processados/brasil_completo.json`
- **Saída Textual**: `out/processados/brasil_completo.json`
- **Saída de Mídia**: `out/processados/brasil_completo_media.json` (gerado obrigatoriamente; contém `{"articles": []}` se nenhuma mídia for identificada)
Regra:
- Diretório: mesmo diretório da saída textual informada.
- Nome base (stem): mesmo stem da saída textual informada.
- Sufixo: `_media`.
- Extensão: `.json`.
- Geração: O arquivo `*_media.json` MUST ser gerado em toda execução bem-sucedida do lote, sem omissão condicional.
---
## 3. Estrutura do Arquivo de Saída Textual (`*_extracted.json`)
```json
{
"source_file": "out/river_plate.json",
"processed_at": "2026-08-24T20:30:00.000000+00:00",
"total_articles": 8,
"successful_articles": 7,
"failed_articles": 1,
"articles": [
{
"input_meta": {
"titulo": "Notícia textual completa",
"url": "https://example.com/noticia-1",
"subtitulo": "Subtítulo",
"quando_publicado": "há 2 horas",
"pagina": 1
},
"extraction_status": "success",
"error_message": null,
"crawled_url": "https://example.com/noticia-1",
"page_title": "Título no DOM",
"http_status": 200,
"trafilatura": { "text": "...", "markdown": "...", "title": "..." },
"newspaper4k": { "text": "...", "summary": "...", "keywords": [] },
"readability": { "cleaned_text": "...", "cleaned_html": "..." }
},
{
"input_meta": {
"titulo": "Notícia onde todos provedores falharam",
"url": "https://example.com/falha"
},
"classification_status": "failed",
"error_message": "media classification providers unavailable",
"crawled_url": "https://example.com/falha",
"page_title": null,
"http_status": null,
"trafilatura": null,
"newspaper4k": null,
"readability": null
}
]
}
```
---
## 4. Estrutura do Arquivo de Saída de Mídia (`*_media.json`)
```json
{
"articles": [
{
"input_meta": {
"titulo": "Vídeo dos melhores momentos do jogo",
"url": "https://example.com/video-gols",
"subtitulo": "Assista ao lance",
"quando_publicado": "há 1 hora",
"pagina": 1
},
"crawled_url": "https://example.com/video-gols",
"page_title": "Vídeo dos Melhores Momentos",
"http_status": 200,
"content_type": "media",
"media_type": "video"
}
]
}
```
---
## 5. Códigos de Saída (Exit Codes)
| Exit Code | Significado |
|:---|:---|
| `0` | Lote processado com sucesso (arquivos gravados) |
| `1` | Arquivo de entrada inexistente ou erro de argumentos CLI |
| `2` | Erro fatal não tratado |
| `130` | Interrupção pelo usuário (`SIGINT` / Ctrl+C) |
@@ -0,0 +1,56 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "MediaOutputFileSchema",
"description": "Schema de validação do arquivo de saída de mídias (*_media.json)",
"type": "object",
"required": [
"articles"
],
"additionalProperties": false,
"properties": {
"articles": {
"type": "array",
"description": "Lista de publicações classificadas como predominantemente de mídia",
"items": {
"type": "object",
"required": [
"input_meta",
"crawled_url",
"page_title",
"http_status",
"content_type",
"media_type"
],
"additionalProperties": false,
"properties": {
"input_meta": {
"type": "object",
"required": [
"titulo",
"url"
],
"properties": {
"titulo": { "type": "string" },
"url": { "type": "string" },
"subtitulo": { "type": ["string", "null"] },
"quando_publicado": { "type": ["string", "null"] },
"pagina": { "type": ["integer", "null"] }
},
"additionalProperties": true
},
"crawled_url": { "type": "string" },
"page_title": { "type": ["string", "null"] },
"http_status": { "type": ["integer", "null"] },
"content_type": {
"type": "string",
"enum": ["media"]
},
"media_type": {
"type": "string",
"enum": ["video", "image", "images", "embed", "mixed"]
}
}
}
}
}
}
@@ -0,0 +1,138 @@
# Data Model & State Transitions: Classificação e Roteamento de Notícias de Mídia
**Feature**: `007-media-article-routing`
**Date**: 2026-08-24
**Status**: Completed
---
## 1. Diagrama Entidade-Relacionamento e Fluxo de Dados
```mermaid
flowchart TD
Input[InputArticle JSON] --> Crawler[Foxcape Crawler]
Crawler --> LoadedPage[DOM Carregada: html, title, status]
LoadedPage --> Gate[DOM Media Gate: detecção estrutural]
Gate -- "Sem mídia candidata relevante" --> TextPipeline[Multimotor Textual: Trafilatura + Newspaper4k + Readability]
Gate -- "Com mídia candidata relevante" --> CompactBuilder[Montagem de Payload Compacto]
CompactBuilder --> Classifier[Cadeia Sequencial LLM: Ollama -> Groq -> OmniRoute]
Classifier -- "content_type = text" --> TextPipeline
TextPipeline --> ExtractedRecord[ExtractedArticle: Sucesso Textual]
ExtractedRecord --> MainReport[ExtractionBatchReport -> *_extracted.json]
Classifier -- "content_type = media" --> MediaRecord[MediaArticle: Registro de Mídia]
MediaRecord --> MediaEnvelope[MediaOutputFile -> *_media.json]
Classifier -- "Falha total dos 3 provedores" --> FailureRecord[Registro de Falha de Classificação]
FailureRecord --> MainReport
```
---
## 2. Modelos de Dados (Dataclasses e Estruturas de Runtime)
### 2.1 `InputArticle`
Representa a notícia original carregada do JSON de entrada.
- `titulo: str` (Obrigatório)
- `url: str` (Obrigatório)
- `subtitulo: str | None` (Opcional, preservado conforme entrada)
- `quando_publicado: str | None` (Opcional, preservado conforme entrada)
- `pagina: int | None` (Opcional, preservado conforme entrada)
- Campos adicionais da entrada são preservados no dicionário `input_meta`.
### 2.2 `MediaCandidateInfo` (Estrutura Interna do Gate)
Resultado da análise estrutural da DOM:
- `has_candidate_media: bool`
- `has_video: bool`
- `image_count: int`
- `has_embed: bool`
### 2.3 `MediaClassification` (Estrutura Interna de Saída do LLM)
Resultado estrito retornado pelo classificador LLM:
- `content_type: Literal["text", "media"]`
- `media_type: Literal["video", "image", "images", "embed", "mixed"] | None`
- Regra semântica: `content_type == "text"` $\iff$ `media_type is None`.
### 2.4 `MediaArticle` (Persistência em `*_media.json`)
Registro consolidado de publicação classificada como mídia:
- `input_meta: dict[str, Any]` (Preserva todos os metadados recebidos da entrada)
- `crawled_url: str`
- `page_title: str | None`
- `http_status: int | None`
- `content_type: Literal["media"]`
- `media_type: Literal["video", "image", "images", "embed", "mixed"]`
### 2.5 `ExtractedArticle` (Persistência Textual em `*_extracted.json`)
Representa exclusivamente artigos textuais que efetivamente passaram pelo multimotor (`extract_all_engines`):
- `input_meta: InputArticle`
- `extraction_status: Literal["success", "failed"]`
- `error_message: str | None`
- `crawled_url: str`
- `page_title: str | None`
- `http_status: int | None`
- `trafilatura: TrafilaturaData | None`
- `newspaper4k: NewspaperData | None`
- `readability: ReadabilityData | None`
### 2.6 Registro de Falha de Classificação (Persistência Inline em `*_extracted.json`)
Para artigos que sofram falha operacional dos três provedores LLM, o registro é persistido inline no array `articles` do JSON principal sem ter executado os extratores textuais:
- `input_meta: dict[str, Any]`
- `crawled_url: str`
- `page_title: str | None`
- `http_status: int | None`
- `classification_status: Literal["failed"]`
- `error_message: str`
- `trafilatura: None`
- `newspaper4k: None`
- `readability: None`
### 2.7 `ExtractionBatchReport`
Envelope consolidado de saída gravado no arquivo principal (`*_extracted.json`):
- `source_file: str`
- `processed_at: str` (ISO 8601 UTC)
- `total_articles: int` (Total de registros presentes no array `articles` do JSON principal)
- `successful_articles: int` (Total de artigos textuais processados com sucesso)
- `failed_articles: int` (Total de registros de falha presentes, incluindo erros de crawl e de classificação)
- `articles: list[dict[str, Any] | ExtractedArticle]`
### 2.8 `MediaOutputFile`
Envelope mínimo gravado no arquivo de mídia (`*_media.json`):
- `articles: list[MediaArticle]`
---
## 3. Máquina de Estados do Processamento de Artigo
```mermaid
stateDiagram-v2
[*] --> Crawling
Crawling --> CrawlFailed: Erro HTTP / Timeout Foxcape
CrawlFailed --> MainJSONRecord: Registra falha de crawl
Crawling --> StructuralInspection: DOM carregada com sucesso
StructuralInspection --> TextExtraction: Sem mídia candidata relevante
StructuralInspection --> LLMClassification: Mídia candidata relevante detectada
state LLMClassification {
[*] --> TryOllama
TryOllama --> ValidOutput: Resposta com schema válido
TryOllama --> TryGroq: Falha operacional Ollama
TryGroq --> ValidOutput: Resposta com schema válido
TryGroq --> TryOmniRoute: Falha operacional Groq
TryOmniRoute --> ValidOutput: Resposta com schema válido
TryOmniRoute --> AllProvidersFailed: Falha operacional OmniRoute
}
ValidOutput --> TextExtraction: content_type == text
ValidOutput --> MediaRouting: content_type == media
AllProvidersFailed --> MainJSONRecord: Registra classification_status = failed
TextExtraction --> MainJSONRecord: Executa Trafilatura + Newspaper + Readability
MediaRouting --> MediaJSONRecord: Grava em *_media.json
MainJSONRecord --> [*]
MediaJSONRecord --> [*]
```
+181
View File
@@ -0,0 +1,181 @@
# 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`.
@@ -0,0 +1,68 @@
# Quickstart & Validation Guide: Classificação e Roteamento de Mídia
**Feature**: `007-media-article-routing`
**Date**: 2026-08-24
**Status**: Completed
---
## 1. Configuração de Variáveis de Ambiente (Configuração Externa)
As credenciais e endpoints dos provedores devem ser configurados no ambiente de execução:
```bash
# Provedor Primário (Ollama Local)
OLLAMA_ENDPOINT="http://localhost:11434"
OLLAMA_MODEL="qwen3.5:2b"
OLLAMA_TIMEOUT="10"
# 1º Fallback (Groq)
GROQ_ENDPOINT="https://api.groq.com/openai/v1/chat/completions"
GROQ_API_KEY="gsk_..."
GROQ_MODEL="openai/gpt-oss-20b"
GROQ_TIMEOUT="15"
# 2º Fallback (OmniRoute)
OMNIROUTE_ENDPOINT="https://<seu-endpoint-omniroute>/v1/chat/completions"
OMNIROUTE_API_KEY="omni_..."
OMNIROUTE_MODEL="cgpt-web/gpt-5.5"
OMNIROUTE_TIMEOUT="20"
```
---
## 2. Execução dos Testes Automatizados (CI)
A suíte de testes executa 100% offline em ambiente de CI via simulações controladas:
```bash
# Executar testes unitários e de integração da feature
pytest tests/unit/test_media_classifier.py tests/integration/test_media_routing.py -v
# Validar conformidade com a política Zero-Regex
python tests/scripts/check_zero_regex.py
```
---
## 3. Cenários de Validação Manual / E2E
### Cenário 1: Execução Padrão com Arquivo de Notícias
```bash
python scripts/extract_article_contents.py -i out/river_plate.json
```
**Resultados esperados**:
- `out/river_plate_extracted.json` gerado contendo artigos textuais e contadores atualizados;
- `out/river_plate_media.json` gerado contendo o envelope `{ "articles": [ ... ] }` com as mídias identificadas (ou `{"articles": []}` caso nenhuma mídia seja classificada no lote);
- Logs no stderr exibindo as 11 métricas operacionais obrigatórias.
### Cenário 2: Execução com Caminho de Saída Customizado (`-o/--output`)
```bash
python scripts/extract_article_contents.py \
-i out/river_plate.json \
-o out/processados/resultado_custom.json \
-l 5
```
**Resultados esperados**:
- Saída textual em `out/processados/resultado_custom.json`;
- Saída de mídia em `out/processados/resultado_custom_media.json` (gerado obrigatoriamente, contendo as mídias ou `{"articles": []}`).
+117
View File
@@ -0,0 +1,117 @@
# Phase 0 Research: Classificação e Roteamento de Notícias Predominantemente de Mídia
**Feature**: `007-media-article-routing`
**Date**: 2026-08-24
**Status**: Completed
---
## 1. Contexto & Objetivos da Pesquisa
Esta pesquisa detalha as decisões técnicas para a implementação da classificação semântica e roteamento de artigos predominantemente de mídia em [`scripts/extract_article_contents.py`](../../scripts/extract_article_contents.py), em estrita conformidade com:
- `docs/prd_extrator_artigo_media/prd.md`
- `docs/prd_extrator_artigo_media/adr_001.md`
- `docs/prd_extrator_artigo_media/adr_002.md`
- `specs/007-media-article-routing/spec.md`
- `.specify/memory/constitution.md`
---
## 2. Decisões Técnicas Consolidadas
### Decisão 1: Algoritmo de Detecção Estrutural de Mídia Candidata na DOM (Zero-Regex)
- **Decisão**: Utilizar `BeautifulSoup` (parser `html.parser`, biblioteca já instalada e utilizada no repositório) para inspecionar a região da DOM associada ao conteúdo da publicação antes de qualquer chamada LLM ou execução do multimotor textual.
- **Algoritmo Estrutural de Localização**:
1. **Delimitação da Região da Publicação**:
- Localizar nós semânticos de conteúdo editorial na seguinte ordem de precedência: tag `<article>`, tag `<main>`, tag `<div role="main">`, ou tag `<body>` caso nenhuma anterior exista.
- Isolar a análise dessa região, descartando elementos 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. **Identificação de Mídia Candidata Relevante**:
- **Vídeo**: tag `<video>` $\rightarrow$ `has_video = True`. Tags `<source>` pertencentes a `<video>` não são contadas isoladamente.
- **Imagem**: contagem de tags `<img>` reais na região editorial. Wrappers como `<figure>` e `<picture>` não incrementam nem duplicam a contagem.
- **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 da matéria.
3. **Resultado Estrutural**:
- Retorna objeto tipado `MediaCandidateInfo(has_candidate_media: bool, has_video: bool, image_count: int, has_embed: bool)`.
- Se `has_candidate_media == False`: o artigo segue **diretamente** para `extract_all_engines()` sem qualquer chamada a LLM.
- Se `has_candidate_media == True`: constrói o payload compacto e invoca a cadeia sequencial de classificação LLM.
- **Rationale**: Filtro prévio determinístico e de custo zero de LLM, sem regex, sem dicionários por idioma e sem seletores amarrados a domínios específicos.
---
### Decisão 2: Especificação do Payload Compacto Enviado ao Classificador LLM
- **Decisão**: Extrair da DOM carregada uma estrutura lógica compacta com os dados essenciais para a decisão semântica:
- `title`: Título da matéria extraído da tag `<title>` ou `<h1>` da região editorial.
- `text_content`: Textos dos parágrafos (`<p>`) da região editorial normalizados e limpos de marcação HTML, preservando todo o conteúdo jornalístico substancial da matéria sem truncamentos arbitrários de caracteres.
- `media_summary`: Indicadores estruturais identificados no gate (`has_video: bool`, `image_count: int`, `has_embed: bool`).
- **Formato do Prompt Único Multilíngue**:
```text
You are an editorial news classifier. Classify if this news publication is predominantly media or substantive journalistic text.
Publication Title: {title}
Structural Media Present: Video={has_video}, ImagesCount={image_count}, Embed={has_embed}
Text Content:
{text_content}
Definitions:
- "media": The primary informative content is in the media (video, single image, multiple images/gallery, social embed, or mixed), and the text functions essentially as a brief introduction, caption, contextualization, or description.
- "text": The publication contains substantive journalistic text on its own, even if accompanied by illustrative media.
Respond ONLY with a JSON object matching this exact schema:
{"content_type": "text" | "media", "media_type": "video" | "image" | "images" | "embed" | "mixed" | null}
Rules:
- If content_type is "text", media_type MUST be null.
- If content_type is "media", media_type MUST be one of: "video", "image", "images", "embed", "mixed".
```
- **Rationale**: Payload enxuto sem HTML desnecessário, permitindo decisão semântica precisa pelo modelo.
---
### Decisão 3: Cliente HTTP e Cadeia Sequencial de Provedores LLM
- **Decisão de Cliente HTTP**: Utilizar funções diretas com a biblioteca padrão do Python (`urllib.request` / `urllib.error` / `json`), que já é o padrão estabelecido no repositório, sem adicionar novas dependências ao projeto.
- **Reutilização de `src/tools/adapters/llm.py`**: O módulo `llm.py` existente é altamente especializado na desambiguação de entidades ECP da feature 001 com classes acopladas (`ECPSnapshot`, `DecisionCategory`); acoplá-lo à classificação de mídia criaria emaranhamento desnecessário de domínios. A implementação com funções diretas via `urllib.request` em `scripts/extract_article_contents.py` é a solução mais desacoplada, limpa e com menor diff.
- **Configuração Externa dos Provedores**:
1. **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: passa o JSON Schema diretamente no campo `format` da requisição `/api/chat` com `options: {"temperature": 0.0}` e `think: false` para desabilitar explicitamente o thinking no Qwen3.5 2B.
2. **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: `response_format={"type": "json_schema", "json_schema": {"name": "media_classifier", "strict": True, "schema": <schema>}}`, `temperature: 0.0` e `reasoning_effort: "low"`.
3. **OmniRoute (2º Fallback)**:
- Endpoint: `os.environ.get("OMNIROUTE_ENDPOINT")` (configuração externa obrigatória no ambiente 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: `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.
- **Regras de Execução e Fallback**:
- Execução estritamente sequencial. Toda resposta que validar contra o schema de 2 campos encerra a cadeia com sucesso.
- Falha operacional (timeout, recusa de conexão, erro HTTP ou schema inválido) transita imediatamente para o próximo provedor.
- Se todos falharem: atribui `classification_status = "failed"` e mensagem diagnóstica, registrando o artigo inline no JSON principal sem multimotor e sem interromper o lote.
---
### Decisão 4: Estrutura de Arquivos e Semântica de Contadores
- **Saída de Mídia (`*_media.json`)**:
Envelope físico contendo estritamente `{ "articles": [ MediaArticle... ] }`.
- **Saída Textual (`*_extracted.json`)**:
Envelope existente `ExtractionBatchReport` onde `total_articles`, `successful_articles` e `failed_articles` refletem exclusivamente os itens persistidos nesse arquivo (artigos textuais + registros com `classification_status = "failed"`).
- **Métricas de Execução**: As 11 métricas obrigatórias são contabilizadas em memória e registradas no log do stderr ao final do lote (respeitando a flag `--silent`).
---
### Decisão 5: Estratégia de Testes e Zero-Regex
- **Testes Determinísticos (CI)**:
- Testes unitários (`tests/unit/test_media_classifier.py`) cobrindo gate estrutural DOM, montagem de compact payload e simulação dos 5 estados da cadeia de provedores sem chamadas de rede externas.
- Testes de integração (`tests/integration/test_media_routing.py`) cobrindo o fluxo em lote completo, cenários A a M, roteamento com e sem `-o` e contadores.
- **Verificação Zero-Regex**:
- Reutilização do script existente [`tests/scripts/check_zero_regex.py`](../../tests/scripts/check_zero_regex.py) incluindo os arquivos de teste e módulos da feature no escopo de verificação AST.
+221
View File
@@ -0,0 +1,221 @@
# Feature Specification: Classificação e Roteamento de Notícias Predominantemente de Mídia
**Feature Branch**: `007-media-article-routing`
**Created**: 2026-08-24
**Status**: Draft
**Input**: User description: "Classificação e Roteamento de Notícias Predominantemente de Mídia no script scripts/extract_article_contents.py conforme docs/prd_extrator_artigo_media (prd.md, adr_001.md, adr_002.md)"
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Roteamento Exclusivo de Notícias Predominantemente de Mídia (Priority: P1)
Como operador do pipeline de notícias, quero que publicações jornalísticas cujo conteúdo informativo principal seja uma mídia (vídeo, imagem única, coleção/múltiplas imagens, post incorporado/embed ou combinação mista de mídias) e cujo texto atue apenas como introdução, legenda, contextualização ou breve descrição dessa mídia sejam identificadas logo após o crawl e salvas em um arquivo JSON próprio (`*_media.json`), sem passar pelo pipeline multimotor textual (Trafilatura, Newspaper4k e Readability).
**Why this priority**: É o objetivo central da funcionalidade: evitar processamento desnecessário de páginas não textuais pelo multimotor e separar fisicamente as publicações de mídia dos artigos textuais.
**Independent Test**: Pode ser testado de forma isolada submetendo URLs cujas páginas possuam mídia predominante e texto meramente descritivo/introdutório. O sistema deve gerar o arquivo `*_media.json` com os registros correspondentes e seus metadados mínimos de rastreabilidade, sem disparar qualquer chamada aos três extratores textuais.
**Acceptance Scenarios**:
1. **Cenário B (Vídeo)**: **Given** uma página carregada contendo um vídeo e texto curto apenas introdutório/contextual, **When** a detecção estrutural e o classificador processam a publicação, **Then** o classificador retorna `content_type = "media"` e `media_type = "video"`, o multimotor não é executado e o artigo é gravado em `*_media.json`.
2. **Cenário C (Imagem Única)**: **Given** uma página carregada contendo uma única imagem e texto curto descritivo, **When** o classificador processa a publicação, **Then** o classificador retorna `content_type = "media"` e `media_type = "image"`, o multimotor não é executado e o artigo é gravado em `*_media.json`.
3. **Cenário D (Múltiplas Imagens)**: **Given** uma página carregada contendo múltiplas imagens (em galeria, carousel, slideshow ou sequência vertical) e texto curto, **When** o classificador processa a publicação, **Then** o classificador retorna `content_type = "media"` e `media_type = "images"`, o multimotor não é executado e o artigo é gravado em `*_media.json`.
4. **Cenário E (Conteúdo Incorporado / Embed)**: **Given** uma página carregada contendo um post incorporado (ex: Instagram, TikTok, X) e breve contextualização textual, **When** o classificador processa a publicação, **Then** o classificador retorna `content_type = "media"` e `media_type = "embed"`, o multimotor não é executado e o artigo é gravado em `*_media.json`.
5. **Cenário F (Mídia Mista)**: **Given** uma página carregada contendo mais de uma categoria relevante de mídia (ex: vídeo e imagens) com texto meramente introdutório, **When** o classificador processa a publicação, **Then** o classificador retorna `content_type = "media"` e `media_type = "mixed"`, o multimotor não é executado e o artigo é gravado em `*_media.json`.
---
### User Story 2 - Roteamento Direto e Preservação de Notícias Textuais (Priority: P2)
Como operador do pipeline textual, quero que notícias cujo conteúdo principal seja texto jornalístico substancial (mesmo que acompanhadas de fotos ilustrativas, vídeos ou infográficos) ou páginas sem qualquer elemento estrutural de mídia continuem sendo processadas normalmente pelos três motores de extração (`extract_all_engines`) e salvas no arquivo de saída textual (`*_extracted.json` ou caminho informado em `-o/--output`), preservando integralmente o formato de saída atual e a semântica de seus contadores.
**Why this priority**: Garante que o pipeline textual original não sofra quebra de contrato, regressão ou perda de dados em matérias jornalísticas informativas normais.
**Independent Test**: Pode ser testado submetendo: (a) páginas de texto puro sem mídia, e (b) matérias jornalísticas substanciais de múltiplos parágrafos contendo fotos ou vídeos editoriais. Ambos devem ser encaminhados ao multimotor e consolidados no arquivo de saída textual.
**Acceptance Scenarios**:
1. **Cenário A (Texto sem Mídia)**: **Given** uma página carregada sem elementos estruturais candidatos a mídia na DOM, **When** a detecção estrutural prévia é executada, **Then** o classificador LLM não é chamado e o artigo é encaminhado diretamente ao multimotor textual.
2. **Cenário G (Artigo Textual Longo com Imagem)**: **Given** uma notícia com texto jornalístico substancial contendo uma ou mais imagens ilustrativas, **When** o classificador semântico avalia a publicação, **Then** retorna `content_type = "text"` com `media_type = null`, o artigo é processado pelo multimotor e salvo na saída textual.
3. **Cenário H (Artigo Textual Longo com Vídeo)**: **Given** uma notícia com texto jornalístico substancial contendo um vídeo incorporado, **When** o classificador semântico avalia a publicação, **Then** retorna `content_type = "text"` com `media_type = null`, o artigo é processado pelo multimotor e salvo na saída textual.
---
### User Story 3 - Resiliência com Fallback Operacional Sequencial e Registro de Falhas (Priority: P3)
Como operador do sistema, quero que falhas puramente operacionais/técnicas no provedor primário local (Ollama) acionem sequencialmente o primeiro fallback (Groq) e, se este também falhar operacionalmente, o segundo fallback (OmniRoute), e que em caso de indisponibilidade de todos os provedores, o erro seja registrado explicitamente no JSON principal de processamento sem interromper o lote.
**Why this priority**: Assegura resiliência de produção em execuções de lote sem intervenção manual, mantendo rastreabilidade estrita e isolamento de falhas.
**Independent Test**: Pode ser testado simulando deterministicamente falhas operacionais e de contrato (timeout, recusa de conexão, erro HTTP, schema inválido) nos provedores intermediários e validando a transição sequencial e o comportamento de falha total, sem necessidade de chamadas a provedores reais durante a suíte normal de CI.
**Acceptance Scenarios**:
1. **Cenário I (Falha no Ollama)**: **Given** o provedor primário (Qwen3.5 2B / Ollama) apresentando falha operacional (timeout, erro HTTP ou conexão recusada), **When** um artigo com mídia candidata é classificado, **Then** o sistema aciona o provedor GPT-OSS 20B / Groq e utiliza sua classificação válida para rotear o artigo.
2. **Cenário J (Falha no Ollama e Groq)**: **Given** Ollama e Groq apresentando falha operacional, **When** o artigo é classificado, **Then** o sistema aciona o provedor `cgpt-web/gpt-5.5` / OmniRoute e utiliza sua classificação válida para rotear o artigo.
3. **Cenário K (Falha Total de Todos os Provedores)**: **Given** Ollama, Groq e OmniRoute apresentando falhas operacionais consecutivas, **When** o artigo é processado, **Then** o sistema atribui `classification_status = "failed"` e mensagem de erro diagnóstica, não assume presunção arbitrária de `text` nem de `media`, não executa o multimotor, não envia o registro para `*_media.json`, registra a falha no arquivo JSON principal de processamento e continua processando os demais artigos do lote.
4. **Cenário L (Resposta fora do Schema)**: **Given** um provedor retornando resposta ilegível, truncada ou incompatível com o contrato estruturado obrigatório, **When** a validação de contrato é executada, **Then** a tentativa é tratada como falha operacional do provedor atual e o sistema avança imediatamente para o próximo provedor na cadeia de fallback.
5. **Cenário M (Suporte Multilíngue)**: **Given** páginas de notícias redigidas em qualquer um dos até 10 idiomas suportados pelo sistema (ex: espanhol, inglês, português, francês, alemão, italiano, etc.), **When** a detecção e a classificação são executadas, **Then** o sistema classifica corretamente textos curtos com mídia como `media` e textos substanciais com mídia como `text`, sem utilizar regras ou prompts específicos por idioma.
---
### Edge Cases
- **Texto extremamente curto com uma única imagem (notícia curta com imagem)**: Deve ser deliberadamente classificado como `content_type = "media"` e `media_type = "image"`, pois texto com volume insuficiente não é útil para o pipeline textual.
- **Avaliação de brevidade textual ("3 a 5 linhas")**: A referência de 3 a 5 linhas de texto é exclusivamente conceitual. O modelo deve avaliar semanticamente se o texto possui conteúdo jornalístico substancial por si próprio ou se funciona apenas como introdução/descrição da mídia, sem depender de contagem literal de linhas renderizadas, viewport, resolução, CSS, número de palavras ou caracteres.
- **Coleções de imagens (galerias / carousels / slideshows / sequência vertical)**: O sistema não deve tentar interagir com o carousel, clicar em botões, avançar slides ou extrair URLs das imagens. Deve apenas identificar estruturalmente a presença de múltiplas imagens e classificar como `media_type = "images"`.
- **Conteúdo misto sem precedência artificial**: Quando houver mais de um tipo relevante de mídia atuando como elemento informativo principal (ex: vídeo e galeria de fotos), o sistema deve classificar como `media_type = "mixed"`, sem impor regras artificiais de precedência como `video > image`.
- **Falha isolada por artigo**: A falha na classificação ou no processamento de um artigo específico não pode interromper nem abortar a execução do lote.
---
## Requirements *(mandatory)*
### Functional Requirements
#### Detecção Estrutural Prévia
- **FR-001**: O sistema MUST analisar estruturalmente a DOM carregada pelo crawler antes de qualquer chamada aos motores Trafilatura, Newspaper4k e Readability.
- **FR-002**: A análise estrutural da DOM MUST ser realizada exclusivamente via parser HTML/DOM (navegação por nós, tags, atributos e contagem de elementos), sendo estritamente proibido o uso de regex (`re`), listas de palavras-chave ou heurísticas semânticas por idioma em toda a nova implementação.
- **FR-003**: Quando nenhuma mídia candidata estiver presente como elemento estruturalmente relevante ao conteúdo da publicação na DOM carregada (sem imagem, vídeo, iframe/embed, object ou estruturas DOM equivalentes associadas à publicação), o artigo MUST seguir diretamente para o pipeline multimotor textual sem chamada ao classificador LLM.
- **FR-004**: Quando houver mídia candidata presente e estruturalmente relevante ao conteúdo da publicação na DOM carregada (como imagem, vídeo, iframe/embed, object ou estruturas equivalentes associadas à publicação), o sistema MUST submeter uma representação compacta da publicação ao classificador de conteúdo. A mera presença de mídia em outras regiões da página não associadas ao conteúdo da publicação não deve, isoladamente, tornar o artigo candidato.
#### Entrada e Escopo do Classificador
- **FR-005**: O classificador MUST receber apenas dados textuais e estruturais compactos já disponíveis na página carregada (título, blocos textuais relevantes, informação estrutural de mídias presentes e quantidade/tipos encontrados), evitando o envio do HTML completo quando a representação compacta contiver a mesma informação.
- **FR-006**: O sistema MUST NOT baixar imagens, baixar vídeos, executar OCR, executar visão computacional, assistir vídeos, transcrever áudios, navegar em carousels/slideshows nem chamar APIs externas de plataformas de mídia.
#### Contrato Estruturado do Classificador
- **FR-007**: A saída emitida pelo classificador LLM MUST conter exclusivamente os campos `content_type` e `media_type` em formato JSON estruturado (sem campos como `provider_used`, `status`, `error_message`, `confidence`, `rationale`, `summary`, `keywords`, `evidence` ou `tradução`).
- **FR-008**: O campo `content_type` emitido pelo modelo MUST aceitar exclusivamente os valores `"text"` ou `"media"`.
- **FR-009**: O campo `media_type` emitido pelo modelo MUST aceitar exclusivamente os valores `"video"`, `"image"`, `"images"`, `"embed"`, `"mixed"` quando `content_type = "media"`, e MUST ser obrigatoriamente `null` quando `content_type = "text"`.
- **FR-010**: O prompt enviado ao classificador MUST ser único, conciso, comum a todos os idiomas e solicitar exclusivamente a classificação necessária (`content_type` e `media_type`). O prompt MUST NOT solicitar reasoning, confidence, rationale, summary, keywords, evidence ou tradução. Reasoning deliberativo adicional não deve ser habilitado quando não for necessário para produzir o contrato estruturado da classificação, e novos campos não devem ser adicionados à resposta.
- **FR-011**: Sempre que suportado pelo provedor, a requisição ao modelo MUST utilizar structured output nativo (JSON Schema / response_format). Toda resposta MUST ser estritamente validada contra o contrato antes de ser aceita.
#### Roteamento, Persistência e Regras de Arquivos
- **FR-012**: Artigos classificados como `content_type = "text"` MUST seguir normalmente pelo fluxo textual existente, executando os três motores de extração (`extract_all_engines`) e sendo salvos no arquivo JSON de saída textual.
- **FR-013**: Artigos classificados como `content_type = "media"` MUST NOT executar os motores Trafilatura, Newspaper4k ou Readability.
- **FR-014**: Artigos classificados como `content_type = "media"` MUST ser gravados em arquivo JSON de mídia dedicado com sufixo `_media.json`, obedecendo às seguintes regras de nomenclatura e diretório:
- **Sem `-o/--output`**: para uma entrada `<dir>/<stem>.json` (ex: `out/river_plate.json`), a saída textual padrão é `<dir>/<stem>_extracted.json` (`out/river_plate_extracted.json`) e a saída de mídia é `<dir>/<stem>_media.json` (`out/river_plate_media.json`).
- **Com `-o/--output` customizado**: para uma saída textual informada como `<custom_dir>/<custom_stem>.json` (ex: `-o out/processados/brasil_completo.json`), a saída de mídia MUST ser gravada em `<custom_dir>/<custom_stem>_media.json` (`out/processados/brasil_completo_media.json`), utilizando o mesmo diretório e stem do output textual com sufixo `_media` e extensão `.json`.
- O arquivo `*_media.json` MUST ser gerado em toda execução bem-sucedida do lote. Quando nenhum artigo for classificado como `content_type = "media"`, o arquivo MUST conter exatamente `{"articles": []}`. O sistema MUST NOT adotar comportamento condicional de omissão do arquivo.
- A interface CLI existente MUST permanecer inalterada, sem adição de parâmetros como `--media-output`.
- **FR-015**: O arquivo `*_media.json` MUST utilizar um envelope físico mínimo contendo exclusivamente a chave `"articles"`, onde cada elemento representa um registro de `MediaArticle` com seus campos mínimos obrigatórios de identificação e rastreabilidade:
```json
{
"articles": [
{
"input_meta": {
"titulo": "...",
"url": "..."
},
"crawled_url": "...",
"page_title": "...",
"http_status": 200,
"content_type": "media",
"media_type": "video"
}
]
}
```
Quando nenhum artigo de mídia for identificado no lote, o envelope MUST ser emitido exatamente como `{"articles": []}`. `input_meta` preserva os metadados recebidos da entrada (onde `titulo` e `url` são obrigatórios, e `subtitulo`, `quando_publicado`, `pagina` e demais metadados existentes continuam opcionais conforme a entrada). O envelope de mídia MUST NOT conter `total_articles`, `successful_articles`, `failed_articles`, contadores por `media_type`, status agregados, modelo de relatório `MediaBatchReport` ou métricas duplicadas (as métricas operacionais são expostas exclusivamente via logging/resumo de execução conforme FR-028).
- **FR-016**: O sistema MUST NOT adicionar em `*_media.json` campos de extração aprofundada de mídia como: `image_urls`, `video_urls`, `embed_urls`, `thumbnails`, `duration`, `captions`, `transcripts`, `OCR`, `alt-text gerado`, `descrição da imagem`, `provider da mídia`, `metadata da mídia`, `summary`, `keywords` ou `sentiment`.
- **FR-017**: O fluxo textual existente (`*_extracted.json` ou arquivo informado em `-o/--output`) MUST preservar o envelope de saída existente (`source_file`, `processed_at`, `total_articles`, `successful_articles`, `failed_articles`, `articles`). Como os artigos de mídia são desviados para `*_media.json` e não permanecem no array `articles` do JSON principal, a semântica dos contadores MUST representar estritamente os registros presentes no arquivo JSON principal:
- `total_articles`: quantidade total de registros presentes no array `articles` do JSON principal;
- `successful_articles`: quantidade de artigos textuais processados com sucesso pelo multimotor e presentes no JSON principal;
- `failed_articles`: quantidade de registros de falha presentes no JSON principal (incluindo falhas do crawl ou com `classification_status = "failed"`).
O JSON principal MUST NOT conter campos adicionais como `media_articles`, contadores de artigos desviados, referências ao `*_media.json` ou novos campos agregados de relatório.
#### Cadeia Sequencial de Provedores e Fallback Operacional
- **FR-018**: O sistema MUST utilizar Qwen3.5 2B executado localmente via Ollama como classificador primário determinístico.
- **FR-019**: O sistema MUST acionar o primeiro fallback operacional (GPT-OSS 20B via Groq) estritamente em caso de falha operacional do Ollama (timeout, erro HTTP, indisponibilidade ou resposta fora do schema).
- **FR-020**: O sistema MUST acionar o segundo fallback operacional (`cgpt-web/gpt-5.5` via OmniRoute) estritamente em caso de falha operacional cumulativa do Ollama e do Groq.
- **FR-021**: Os provedores de classificação MUST ser executados de forma estritamente sequencial (uma resposta válida encerra imediatamente a cadeia). É expressamente proibida a execução paralela, votação entre modelos, consenso, segunda opinião ou fallback condicionado a valor de classificação ou nível de confiança.
- **FR-022**: Em caso de falha operacional dos três provedores (Ollama, Groq e OmniRoute), o artigo MUST receber `classification_status = "failed"` e mensagem diagnóstica de erro sem exposição de credenciais, MUST permanecer registrado no JSON principal de processamento, MUST NOT ser enviado ao arquivo de mídia, MUST NOT executar o multimotor e MUST NOT ter classificação presumida como `text` ou `media`. A falha não deve interromper os demais artigos do lote.
- **FR-023**: O sistema MUST NOT criar arquivos físicos adicionais exclusivos para falhas (ex: `*_failed.json`), filas de mensagens, DLQ ou serviços assíncronos de recuperação.
- **FR-024**: A cadeia de provedores de classificação MUST ser testável de forma determinística sem depender de chamadas reais aos provedores externos durante a suíte normal automatizada (CI). Devem ser simuláveis/testáveis, no mínimo: (1) Ollama success; (2) Ollama fail → Groq success; (3) Ollama fail → Groq fail → OmniRoute success; (4) todos falham; e (5) provedor retorna resposta fora do schema. Não é exigida presença de Ollama, Groq ou OmniRoute reais na execução regular da suíte de testes.
#### Segurança, Configuração Externa e Observabilidade
- **FR-025**: Endpoints, nomes de modelos, credenciais de API e timeouts dos provedores MUST ser configuráveis externamente ao código-fonte, não sendo permitidos segredos hardcoded.
- **FR-026**: Logs, saídas JSON e mensagens de erro persistidas MUST NOT registrar API keys, tokens de autenticação ou credenciais.
- **FR-027**: O sistema MUST registrar informações operacionais estruturadas nos logs existentes para identificar: provedor utilizado, acionamento do fallback Ollama → Groq, acionamento do fallback Groq → OmniRoute, classificação final obtida e eventual falha total da cadeia, sem registrar o payload textual integral do artigo por padrão.
- **FR-028**: O sistema MUST contabilizar e expor nos mecanismos de logging/resumo de execução existentes as seguintes métricas operacionais obrigatórias:
- total de artigos avaliados;
- total roteado para `text`;
- total roteado para `media`;
- total `media/video`;
- total `media/image`;
- total `media/images`;
- total `media/embed`;
- total `media/mixed`;
- quantidade de acionamentos do fallback Groq;
- quantidade de acionamentos do fallback OmniRoute;
- total de falhas de classificação.
- **FR-029**: O classificador e a detecção estrutural MUST funcionar de forma independente de idioma, cobrindo com precisão os até 10 idiomas processados pelo sistema.
- **FR-030**: O script `scripts/extract_article_contents.py` MUST manter compatibilidade com sua interface de linha de comando (`-i/--input`, `-o/--output`, `-l/--limit`, `--lang/--language`, `-t/--timeout`, `-s/--silent`).
---
### Key Entities
- **InputArticle**: Metadados originais da notícia de entrada carregados do arquivo de busca (`titulo` e `url` obrigatórios; `subtitulo`, `quando_publicado`, `pagina` e eventuais campos extras preservados conforme a entrada).
- **MediaArticle**: Registro consolidado de publicação predominantemente de mídia gravado no array `articles` de `*_media.json`, contendo os campos mínimos obrigatórios de rastreabilidade (`input_meta`, `crawled_url`, `page_title`, `http_status`, `content_type` e `media_type`).
- **ExtractedArticle**: Registro consolidado de artigo textual efetivamente processado pelos motores de extração multimotor (`extract_all_engines`) e gravado no array `articles` do JSON principal de saída textual, contendo `input_meta`, `extraction_status`, `error_message`, `crawled_url`, `page_title`, `http_status`, `trafilatura`, `newspaper4k` e `readability`.
- **ExtractionBatchReport**: Relatório consolidado do lote de extração textual gravado no arquivo JSON principal de saída contendo os contadores restritos aos itens presentes nesse arquivo (`total_articles`, `successful_articles`, `failed_articles`) e a lista `articles`.
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% das publicações identificadas como predominantemente de mídia (`video`, `image`, `images`, `embed`, `mixed`) são desviadas antes da execução dos motores Trafilatura, Newspaper4k e Readability.
- **SC-002**: 100% das publicações de mídia identificadas são gravadas no arquivo `*_media.json` dentro do envelope `articles` contendo os campos mínimos obrigatórios definidos no contrato de rastreabilidade (`input_meta`, `crawled_url`, `page_title`, `http_status`, `content_type`, `media_type`).
- **SC-003**: 0% de alteração de contrato ou degradação de campos nos arquivos JSON de saída textual para artigos normais processados com sucesso, com contadores refletindo estritamente os itens presentes no arquivo.
- **SC-004**: 100% das falhas operacionais intermediárias em provedores LLM transitam de forma determinística e sequencial pela cadeia (Ollama → Groq → OmniRoute) sem interrupção manual ou falha espúria.
- **SC-005**: 100% das tentativas com falha cumulativa dos três provedores resultam em registro de erro explícito (`classification_status = "failed"`) no arquivo JSON principal de processamento sem parar o processamento dos demais itens do lote.
- **SC-006**: 100% dos cenários mínimos e requisitos funcionais obrigatórios possuem cobertura em suíte de testes automatizados (unitários e de integração), contemplando páginas sem mídia, mídias individuais, mídias mistas, fallbacks operacionais simulados deterministicamente e artigos em todos os idiomas suportados pelo sistema.
- **SC-007**: 0% de uso de bibliotecas ou chamadas de expressões regulares (`re`) em toda a nova lógica de detecção estrutural e classificação de mídia.
- **SC-008**: 0 ocorrências de credenciais, API keys ou tokens expostos em logs, mensagens de erro ou saídas JSON.
---
## Assumptions
- **Configuração de Provedores**: O ambiente de produção deve possuir configuração para o runtime local do Ollama (Qwen3.5 2B) e credenciais para os provedores de fallback Groq (GPT-OSS 20B) e OmniRoute (`cgpt-web/gpt-5.5`). A indisponibilidade operacional de um provedor durante a execução aciona sequencialmente o fallback definido.
- **Isolamento de Responsabilidade da Mídia**: O download, visão computacional, OCR, transcrição e indexação da mídia são responsabilidades exclusivas de sistemas ou etapas downstream, não fazendo parte desta funcionalidade.
- **Decisão Semântica de Conteúdo**: A distinção entre texto substancial e texto curto introdutório/contextual é uma avaliação semântica realizada pelo modelo de linguagem compacto, não sendo calculada por contagem de linhas visuais renderizadas ou regras lexicais.
- **Isolamento de Falha**: A falha operacional na classificação de uma notícia isolada não invalida nem interrompe a execução dos demais artigos presentes no mesmo lote.
---
## Out of Scope *(Explicitamente Fora de Escopo)*
Para evitar overengineering e manter a solução estritamente aderente aos princípios obrigatórios, os seguintes itens estão **explicitamente fora de escopo**:
- Download de imagens, vídeos ou arquivos binários de mídia;
- Extração de URLs internas de imagens, vídeos ou embeds;
- OCR (Reconhecimento Óptico de Caracteres) ou visão computacional;
- Transcrição de áudio ou vídeo;
- Identificação de objetos, fotografias, infográficos ou ilustrações;
- Geração de descrições, resumos, palavras-chave ou análise de sentimento;
- Interação com carousels, slideshows ou navegação em galerias;
- Chamadas a APIs externas dos provedores das mídias (Instagram, TikTok, X, etc.);
- Detecção, classificação ou filtragem de publicidade / anúncios;
- Armazenamento em banco de dados nesta feature (nenhum banco);
- Criação de qualquer worker novo (nenhum worker novo);
- Criação de pipeline adicional de NLP (nenhum pipeline NLP adicional);
- Criação ou introdução de nova plataforma de observabilidade (nenhuma nova plataforma de observabilidade);
- Criação de novos serviços, microserviços ou processos autônomos;
- Introdução de filas de mensagens, DLQ ou mecanismos adicionais de retry em fila;
- Uso de frameworks de agentes, LangChain, LangGraph ou LLM-as-a-judge;
- Votação entre modelos, scoring complexo ou consenso de LLMs;
- Classificação baseada em regex ou dicionários de palavras-chave por idioma;
- Criação de novos modelos físicos de relatório como `MediaBatchReport`;
- Criação de novas entidades de domínio ou DTOs para falhas (ex: `FailedArticle`, `ClassificationFailedArticle`);
- Criação de arquivos físicos adicionais como `*_failed.json`;
- Criação de novos parâmetros de linha de comando como `--media-output`;
- Alterar o comportamento de carregamento/crawl existente do Foxcape/Camoufox, ou a lógica interna de Trafilatura, Newspaper4k e Readability. A nova funcionalidade consome o HTML já retornado pelo crawler e realiza a decisão de roteamento antes da execução de `extract_all_engines()`.
+148
View File
@@ -0,0 +1,148 @@
# Tasks: Classificação e Roteamento de Notícias Predominantemente de Mídia
**Feature**: `007-media-article-routing`
**Input**: [spec.md](spec.md), [plan.md](plan.md), [data-model.md](data-model.md), [research.md](research.md), [contracts/](contracts/)
---
## Phase 1: Foundational (Blocking Prerequisites)
**Purpose**: Estruturas de dados internas, constante do schema e helper de comunicação HTTP via `urllib.request` que bloqueiam a implementação das histórias.
- [x] T001 Implement `MediaCandidateInfo` dataclass and `MediaClassification` type definition in `scripts/extract_article_contents.py`
- [x] T002 Define `MEDIA_CLASSIFIER_SCHEMA` constant (flat 2-field schema matching `contracts/classifier-io.schema.json`) and implement `validate_classifier_response(data: dict) -> MediaClassification | None` in `scripts/extract_article_contents.py`
- [x] T003 Implement `urllib.request` JSON HTTP dispatch helper `_http_post_json(url: str, payload: dict, headers: dict, timeout: int) -> tuple[int, str]` in `scripts/extract_article_contents.py`
- Serializar request JSON, configurar headers e executar chamada via `urllib.request`;
- Retornar tupla `(http_status, response_body)`;
- Não converter erros de transporte para status mágicos (ex: 0/-1); exceções operacionais (`URLError`, `HTTPError`, timeout) devem propagar para captura e tratamento centralizado de fallback em `classify_media_content()`.
**Checkpoint**: Base foundational pronta — implementação das histórias de usuário pode prosseguir.
---
## Phase 2: User Story 1 - Detecção e Roteamento de Publicações Predominantemente de Mídia (Priority: P1)
**Goal**: Identificar publicações cujo conteúdo informativo principal seja mídia (vídeo, imagem, imagens, embed ou mídia mista), desviar antes dos extratores textuais e persistir em `*_media.json` com envelope mínimo `{ "articles": [...] }`.
**Independent Test**: Submeter amostras de páginas de mídia com texto introdutório curto (cenários B, C, D, E, F); verificar que `extract_all_engines` NÃO é chamado e que o arquivo `*_media.json` é gerado contendo o envelope e os registros `MediaArticle`.
- [x] T004 [US1] Unit tests for schema validation, DOM structural gate (`detect_candidate_media`) and compact payload generation (`build_compact_payload`) in `tests/unit/test_media_classifier.py`
- Validar `validate_classifier_response`: `content_type=text` + `media_type=null` (válido), `content_type=text` + `media_type=image` (inválido), `content_type=media` + `media_type=video` (válido), `content_type=media` + `media_type=null` (inválido), valores desconhecidos (inválido), campo obrigatório ausente (inválido), campo extra (inválido);
- Validar detecção de `<video>`;
- Validar que `<source>` isolado NÃO é vídeo;
- Validar que `<figure><img></figure>` e `<picture><img></picture>` contam exatamente 1 imagem;
- Validar contagem exata de múltiplos `<img>`;
- Validar detecção de `<iframe>`, `<embed>`, `<object>` como embeds;
- Validar que mídia em `<header>`, `<nav>`, `<footer>` e `<aside>` não aciona o gate;
- Validar ordem estrutural (`<article>` $\rightarrow$ `<main>` $\rightarrow$ `[role="main"]` $\rightarrow$ `<body>`);
- Validar que `build_compact_payload` extrai `title`, `text_content` (todos os `<p>` editoriais normalizados sem truncamento arbitrário e preservando Unicode) e `media_summary` (`has_video`, `image_count`, `has_embed`).
- [x] T005 [US1] Implement `detect_candidate_media(soup: BeautifulSoup) -> MediaCandidateInfo` identifying editorial region (`<article>`, `<main>`, `[role=main]`, `<body>`) and media elements (`<video>`, real `<img>` count without duplicate wrappers, `<iframe>`/`<embed>`/`<object>`) in `scripts/extract_article_contents.py`
- [x] T006 [US1] Implement `build_compact_payload(soup: BeautifulSoup, candidate_info: MediaCandidateInfo) -> str` extracting title and normalized editorial text in `scripts/extract_article_contents.py`
- [x] T007 [US1] Implement single prompt constant and initial `classify_media_content(payload: str, metrics: dict, silent: bool = False) -> tuple[MediaClassification | None, str | None]` in `scripts/extract_article_contents.py`
- Definir constante de prompt único `MEDIA_CLASSIFIER_PROMPT` em `scripts/extract_article_contents.py` reutilizada por Ollama, Groq e OmniRoute, comum a todos os idiomas, solicitando exclusivamente `content_type` e `media_type`, contendo definições semânticas de text vs media, e sem solicitar reasoning, confidence, rationale, summary, keywords, evidence ou tradução;
- Despachar provedor primário Ollama (`OLLAMA_ENDPOINT`, `OLLAMA_MODEL="qwen3.5:2b"`, `OLLAMA_TIMEOUT=10`, `think=false`, `temperature=0.0`, `format=MEDIA_CLASSIFIER_SCHEMA`) e validar schema da resposta;
- Registrar no stderr o provedor utilizado e a classificação final (`text` ou `media` + `media_type`) respeitando `silent`, sem logar o payload textual integral.
- [x] T008 [US1] Implement media output path resolution, metrics dict initialization, `save_media_json`, and batch routing in `scripts/extract_article_contents.py`
- Inicializar em `process_batch` o dicionário local com as 11 métricas operacionais zeradas;
- Implementar resolução do caminho do arquivo de mídia: sem `-o` $\rightarrow$ `<input_dir>/<input_stem>_media.json` (ex: `out/river_plate.json` $\rightarrow$ `out/river_plate_media.json`); com `-o` $\rightarrow$ `<output_dir>/<output_stem>_media.json` (ex: `-o out/processados/resultado.json` $\rightarrow$ `out/processados/resultado_media.json`) sem novas flags CLI;
- Implementar `save_media_json(articles: list[dict[str, Any]], output_path: Path) -> None` gravando `{ "articles": [...] }`;
- Integrar o fluxo em `process_batch`: após crawl bem-sucedido, carregar HTML no BeautifulSoup e executar `detect_candidate_media()`; se `has_candidate_media == True`, executar `build_compact_payload()` e `classify_media_content(payload, metrics, silent)`; se retornar `content_type == "media"`, adicionar `MediaArticle` em `media_articles` e NÃO executar `extract_all_engines()`.
- [x] T009 [US1] Integration tests for media routing (Cenários B, C, D, E, F), `_media.json` naming resolution, and `input_meta` preservation with custom unknown fields in `tests/integration/test_media_routing.py`
- Validar que campos desconhecidos arbitrários (ex: `custom_field="preserve-me"`, `custom_number=42`) são integralmente preservados em `MediaArticle`.
**Checkpoint**: User Story 1 completa e testável de forma independente com Ollama mockado.
---
## Phase 3: User Story 2 - Roteamento Direto e Preservação de Notícias Textuais (Priority: P2)
**Goal**: Garantir que artigos textuais substantivos (com ou sem mídias ilustrativas) ou páginas sem mídia candidata continuem sendo processados pelos 3 motores textuais em `*_extracted.json`, gerando incondicionalmente `*_media.json` com `{"articles": []}` caso não haja mídias.
**Independent Test**: Submeter página sem mídia candidata (Cenário A) e matérias jornalísticas longas com fotos/vídeos (Cenários G, H); verificar execução dos 3 motores, gravação no JSON textual e presença de `*_media.json` com `{"articles": []}`.
- [x] T010 [US2] Unit tests for direct gate bypass (`has_candidate_media == False`) and textual classification handling in `tests/unit/test_media_classifier.py`
- [x] T011 [US2] Implement direct gate bypass in `process_batch` when `has_candidate_media == False` routing directly to `extract_all_engines` without LLM calls, ensure `content_type == "text"` passes to `extract_all_engines`, and execute `save_media_json` ensuring `_media.json` is always generated (with `{"articles": []}` when zero media articles) in `scripts/extract_article_contents.py`
- [x] T012 [US2] Integration tests for textual articles (Cenários A, G, H), main JSON report counters (`total_articles`, `successful_articles`, `failed_articles`), mandatory generation of `_media.json` with `{"articles": []}`, and `input_meta` preservation in `tests/integration/test_media_routing.py`
- Validar que os mesmos campos desconhecidos arbitrários em `input_meta` sobrevivem integralmente nos registros de artigos textuais em `*_extracted.json`.
**Checkpoint**: User Stories 1 e 2 funcionais e integradas, com preservação estrita do pipeline textual e arquivo de mídia incondicional.
---
## Phase 4: User Story 3 - Resiliência com Fallback Operacional Sequencial e Registro de Falhas (Priority: P3)
**Goal**: Implementar a cadeia sequencial de contingência (Ollama $\rightarrow$ Groq $\rightarrow$ OmniRoute), registrando falhas operacionais cumulativas inline no JSON principal com `classification_status: "failed"` sem abortar o lote e mantendo suporte multilíngue.
**Independent Test**: Simular os 5 estados de provedores (Cenários I, J, K, L, M) via mocks offline; verificar transição Groq/OmniRoute, first-valid-wins, registro de erro inline com `classification_status: "failed"` e continuidade do lote.
- [x] T013 [US3] Unit tests for sequential provider chain in `tests/unit/test_media_classifier.py`
- Validar first-valid-wins (Ollama sucesso $\rightarrow$ Groq e OmniRoute com 0 chamadas; Ollama falha + Groq sucesso $\rightarrow$ OmniRoute com 0 chamadas);
- Validar transição imediata para o próximo provider em caso de timeout, connection error, HTTP error ou schema inválido;
- Validar que providers recebem parâmetros corretos (Ollama: `think: false`, `temperature: 0.0`; Groq: `openai/gpt-oss-20b`, `reasoning_effort: "low"`, `temperature: 0.0`, strict JSON Schema; OmniRoute: `cgpt-web/gpt-5.5`, `temperature: 0.0`, JSON Schema);
- Validar que os 3 providers utilizam o mesmo prompt único, sem variantes por idioma e sem solicitações de campos proibidos (reasoning, confidence, rationale, summary, keywords, evidence, tradução);
- Validar ausência de retries, votação, juiz ou confidence thresholds.
- [x] T014 [US3] Extend `classify_media_content(payload, metrics, silent)` with Groq (`GROQ_ENDPOINT`, `GROQ_API_KEY`, `GROQ_MODEL="openai/gpt-oss-20b"`, `GROQ_TIMEOUT=15`, `reasoning_effort="low"`, `temperature=0.0`, strict JSON Schema) and OmniRoute (`OMNIROUTE_ENDPOINT`, `OMNIROUTE_API_KEY`, `OMNIROUTE_MODEL="cgpt-web/gpt-5.5"`, `OMNIROUTE_TIMEOUT=20`, `temperature=0.0`, JSON Schema), incrementing `fallback_groq` e `fallback_omniroute` imediatamente antes de cada chamada, registrando logs operacionais de fallback no stderr respeitando `silent`, e tratando configuração ausente como falha operacional do provider in `scripts/extract_article_contents.py`
- [x] T015 [US3] Implement cumulative failure handling in `process_batch` recording inline in main JSON with `classification_status = "failed"`, `error_message`, incrementing `classification_failed`, registrando log de falha total no stderr respeitando `silent`, preservando crawl metadata, skipping multimotor, não adicionando o registro de falha ao `media_articles` nem ao array `articles` de `*_media.json`, e incrementing `failed_articles` in `scripts/extract_article_contents.py`
- [x] T016 [US3] Integration tests for cumulative provider failure (Cenário K), fallback transitions (Cenários I, J, L), `input_meta` preservation in failure records, and multilingual support (Cenário M) across supported pipeline languages without language-specific prompts in `tests/integration/test_media_routing.py`
- Validar no Cenário K que `classification_status == "failed"`, `error_message` existe, `extract_all_engines` NÃO é chamado, o registro NÃO entra em `*_media.json`, `failed_articles` incrementa exatamente uma vez, `input_meta` integral é preservado com campos desconhecidos, `crawled_url`, `page_title` e `http_status` disponíveis são preservados, e o lote continua normalmente para o próximo artigo.
**Checkpoint**: As 3 User Stories estão completas, com resiliência total, fallback sequencial determinístico e tratamento de falhas.
---
## Phase 5: Polish & Observabilidade
**Purpose**: Métricas operacionais, segurança de credenciais, compatibilidade CLI e validação estática de Zero-Regex.
- [x] T017 [Polish] Implement in-memory tracking of remaining operational metrics (`total_evaluated`, `text`, `media`, and `media/<subtype>`) and output summary to stderr respecting `--silent` in `scripts/extract_article_contents.py`
- Garantir que as métricas `total_evaluated`, `text`, `media`, `media/video`, `media/image`, `media/images`, `media/embed`, `media/mixed`, `fallback_groq`, `fallback_omniroute` e `classification_failed` sejam incrementadas uma única vez nos pontos definidos, sem duplicidade;
- Ao final do lote, emitir o summary formatado no stderr respeitando `--silent`.
- [x] T018 [P] Verify and update `tests/scripts/check_zero_regex.py` to include `scripts/extract_article_contents.py` and new test files in the scoped AST check
- [x] T019 Integration tests verifying the 11 metrics values, logging in stderr, `--silent` suppression, secret non-exposure, CLI flag compatibility, and exit code preservation in `tests/integration/test_media_routing.py`
- Validar contagem exata das 11 métricas nos pontos definidos;
- Validar que o provider utilizado, acionamentos de fallback (Ollama $\rightarrow$ Groq, Groq $\rightarrow$ OmniRoute), classificação final e eventuais falhas totais são emitidos no stderr quando não silencioso;
- Validar que `--silent` suprime todos os logs operacionais e o resumo de métricas;
- Validar que API keys, tokens e secrets NUNCA aparecem em stderr, JSON de saída ou `error_message`;
- Validar que o payload textual integral enviado ao classificador NÃO aparece no log/stderr por padrão (sem proibir o texto normal pertencente ao contrato de `*_extracted.json`);
- Validar compatibilidade dos argumentos CLI (`-i`, `-o`, `-l`, `--lang`, `-t`, `-s`), resolução dos outputs e preservação dos exit codes existentes (`0`, `1`, `2`), sem criar novos testes complexos de SIGINT/Ctrl+C/exit 130 exclusivamente para esta feature.
- [x] T020 Execute validation commands from `specs/007-media-article-routing/quickstart.md` (`pytest` suite and `python tests/scripts/check_zero_regex.py`)
---
## Dependencies & Execution Order
```mermaid
flowchart TD
Foundational[Phase 1: Foundational T001-T003] --> US1[Phase 2: User Story 1 - P1 T004-T009]
US1 --> US2[Phase 3: User Story 2 - P2 T010-T012]
US2 --> US3[Phase 4: User Story 3 - P3 T013-T016]
US3 --> Polish[Phase 5: Polish & Observability T017-T020]
Polish17[T017] --> Polish19[T019]
Polish18[T018] --> Polish20[T020]
Polish19 --> Polish20
```
### Phase Dependencies
- **Foundational (Phase 1)**: Sem dependências de histórias; implementa tipos, schema e cliente HTTP básico.
- **User Story 1 (Phase 2 - P1)**: Depende de Foundational. Implementa detecção DOM, payload, Ollama e persistência `*_media.json`.
- **User Story 2 (Phase 3 - P2)**: Depende de US1. Implementa bypass direto sem LLM, preservação do pipeline textual e emissão incondicional de `*_media.json` com `[]`.
- **User Story 3 (Phase 4 - P3)**: Depende de US2. Implementa fallbacks Groq/OmniRoute na mesma função `classify_media_content`, tratamento de falhas inline e multilíngue.
- **Polish (Phase 5)**: Depende de US1, US2 e US3. `T017` implementa o fechamento das métricas, `T019` testa métricas, segurança e CLI, `T018` atualiza o checker AST de forma independente, e `T020` executa a validação global final.
---
## Parallel Execution Opportunities
- **Phase 5 (Polish)**: `T018` (atualização do checker AST de zero-regex em `tests/scripts/check_zero_regex.py`) está marcado com `[P]` pois opera em arquivo independente e pode rodar em paralelo às demais tarefas.
---
## Implementation Strategy
A entrega da feature será realizada de forma incremental e ordenada:
1. **Fundação**: Dataclasses, constante do schema e helper HTTP `_http_post_json`.
2. **História 1**: Detecção DOM, compact payload, Ollama, inicialização das 11 métricas e geração de `*_media.json`.
3. **História 2**: Gate bypass sem LLM, preservação textual e geração incondicional de `*_media.json` (com `[]` quando vazio).
4. **História 3**: Extensão da cadeia sequencial com Groq (`reasoning_effort="low"`) e OmniRoute, com registro inline de falha no JSON principal.
5. **Polimento**: Fechamento da contagem e resumo das 11 métricas no stderr, verificação de não-exposição de segredos, compatibilidade CLI e verificação estática Zero-Regex.
6. **Conclusão**: 100% das 20 tarefas concluídas e validadas contra a suíte de testes e o checker Zero-Regex.