Files
TextNLPClassifierApp/specs/007-media-article-routing/research.md
T

8.6 KiB

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, 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:
    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: