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
+368
View File
@@ -0,0 +1,368 @@
# ADR-001 — Classificar e rotear conteúdo predominantemente de mídia antes do multimotor
## Status
Accepted
---
# Contexto
O pipeline atual obtém a página completamente carregada através do crawler e, posteriormente, executa múltiplos motores de extração textual.
Esse desenho é adequado para artigos cujo conteúdo principal é texto.
Entretanto, alguns veículos publicam páginas onde:
* existe apenas uma breve introdução textual;
* o conteúdo principal é um vídeo, imagem, conjunto de imagens ou conteúdo incorporado.
Processar essas páginas com os motores textuais é desnecessário e pode gerar resultados pobres ou irrelevantes.
A responsabilidade de identificar esses casos deve ser adicionada sem alterar o crawler e sem transformar o pipeline em uma nova arquitetura.
---
# Decisão
Adicionar uma etapa de classificação imediatamente após o crawl e antes do multimotor.
Fluxo:
```text
Crawler
↓
DOM completamente carregada
↓
Media Candidate Detection
↓
├── sem mídia candidata
│ ↓
│ multimotor atual
│
└── com mídia candidata
↓
Media Content Classifier
↓
├── text
│ ↓
│ multimotor atual
│
└── media
↓
JSON separado
```
---
# Detecção estrutural
Antes do LLM deve existir apenas uma análise estrutural simples da DOM.
Objetivo:
> verificar se existe algum elemento de mídia que justifique a classificação.
A análise deve operar sobre a DOM/HTML já carregada pelo crawler.
É permitido utilizar:
* parser HTML;
* navegação por nós;
* tags;
* atributos estruturais;
* relações entre elementos;
* contagem de elementos.
É proibido utilizar:
* regex;
* listas de palavras específicas por idioma;
* heurísticas semânticas por idioma.
Essa etapa não decide se a publicação é `media`.
Ela decide apenas se há motivo para chamar o classificador.
---
# Conteúdo enviado ao modelo
O modelo deve receber uma representação compacta dos dados já existentes na página.
Devem ser utilizados somente dados necessários para a decisão, como:
```text
título
blocos textuais relevantes
presença de imagem
quantidade de imagens
presença de vídeo
presença de elementos incorporados
```
Não enviar mídia binária.
Não realizar chamadas externas para compreender a mídia.
Não enviar HTML completo quando uma representação compacta da estrutura puder fornecer a mesma informação.
---
# Responsabilidade do classificador
O classificador responde uma única pergunta conceitual:
> O texto desta publicação possui conteúdo jornalístico substancial por si próprio ou funciona essencialmente como uma breve introdução, contextualização ou descrição da mídia presente?
Se o texto for substancial:
```json
{
"content_type": "text",
"media_type": null
}
```
Se a mídia for o conteúdo principal:
```json
{
"content_type": "media",
"media_type": "..."
}
```
---
# Tipos permitidos
```text
video
image
images
embed
mixed
```
Não criar subtipos adicionais.
---
# Regra para múltiplas imagens
Não é necessário identificar tecnicamente um componente carousel.
Se múltiplas imagens constituem o conteúdo principal, o resultado deve ser:
```text
images
```
Independentemente de serem apresentadas como:
* carousel;
* slideshow;
* galeria;
* sequência vertical;
* qualquer outra composição visual.
---
# Regra para conteúdo misto
Quando mais de uma categoria de mídia constituir o conteúdo principal:
```text
mixed
```
Não criar precedência artificial como:
```text
video > image
```
---
# Artigos textuais
Um artigo continua sendo `text` mesmo contendo mídia quando existe conteúdo jornalístico textual substancial.
Portanto:
```text
presença de mídia ≠ classificação media
```
---
# Artigos muito curtos
Uma publicação extremamente curta com uma imagem pode ser classificada como `media/image`.
Não é necessário tentar preservar esse conteúdo como artigo textual apenas porque existe algum texto.
---
# Posicionamento arquitetural
A nova etapa deve permanecer fora de:
```text
Trafilatura
Newspaper4k
Readability
```
Nenhum dos três motores é responsável pela classificação.
O método equivalente ao atual `extract_all_engines()` somente deve ser executado depois que o novo roteamento determinar:
```text
content_type = text
```
---
# Saída física
Artigos textuais:
```text
*_extracted.json
```
Artigos predominantemente de mídia:
```text
*_media.json
```
A classificação de mídia não deve aparecer misturada aos artigos textuais processados com sucesso.
---
# Dados preservados para mídia
Cada registro de mídia deve preservar somente informações básicas necessárias à rastreabilidade:
```text
input_meta
crawled_url
page_title
http_status
content_type
media_type
```
Não existe extração de mídia nesta feature.
---
# Alternativas consideradas
## Executar primeiro o multimotor e identificar mídia depois
Rejeitada.
Motivos:
* executa trabalho desnecessário;
* mistura responsabilidades;
* não evita custo de processamento;
* mantém páginas inadequadas dentro do pipeline textual.
---
## Usar Newspaper4k para descobrir mídia
Rejeitada.
Embora Newspaper4k possa expor informações de imagens e vídeos, utilizá-lo significaria executar parte do multimotor justamente nos conteúdos que a nova feature pretende desviar antes do multimotor.
---
## Classificar tudo apenas com regras
Rejeitada.
A decisão:
```text
texto jornalístico curto
```
versus:
```text
texto que apenas descreve uma mídia
```
é semântica e precisa funcionar em até 10 idiomas.
Criar heurísticas específicas para resolver isso aumentaria código, manutenção e fragilidade.
---
## Utilizar regex
Rejeitada e proibida por requisito.
---
## Utilizar modelo multimodal
Rejeitada.
O sistema não precisa entender a mídia.
Precisa apenas determinar se o texto é o conteúdo principal ou se serve de introdução à mídia.
---
# Consequências positivas
* reduz processamento desnecessário;
* mantém o multimotor focado em texto;
* separa claramente conteúdos de naturezas diferentes;
* mantém a implementação pequena;
* evita regras linguísticas;
* funciona de forma multilíngue;
* não introduz nova infraestrutura;
* permite evolução futura do pipeline de mídia de forma independente.
---
# Consequências aceitas
Alguns artigos textuais muito curtos acompanhados de imagem poderão ser classificados como `media/image`.
Esse comportamento é deliberado e aceito, pois conteúdos textuais extremamente pobres não são úteis para o objetivo do pipeline textual.
---
# Invariantes
A implementação deve sempre preservar:
```text
MEDIA
→ nunca executa multimotor
TEXT
→ segue pipeline atual
classification_failed
→ não assume TEXT
→ não assume MEDIA
```
E:
```text
nenhuma regex
nenhum download de mídia
nenhuma análise multimodal
nenhuma interação com carousel
```
+516
View File
@@ -0,0 +1,516 @@
# ADR-002 — Utilizar Qwen3.5 2B local com fallback operacional Groq e OmniRoute
## Status
Accepted
---
# Contexto
A classificação entre:
```text
text
```
e:
```text
media
```
exige uma pequena decisão semântica multilíngue.
O problema não exige um modelo grande.
A tarefa é extremamente restrita:
1. receber texto e informações estruturais;
2. decidir se o texto é conteúdo jornalístico substancial ou apenas descrição/contextualização da mídia;
3. quando for mídia, identificar uma entre cinco categorias.
O sistema precisa ser production-ready sem introduzir infraestrutura ou complexidade desnecessária.
---
# Decisão
Utilizar a seguinte cadeia sequencial:
```text
Qwen3.5 2B / Ollama
↓ falha operacional
GPT-OSS 20B / Groq
↓ falha operacional
cgpt-web/gpt-5.5 / OmniRoute
```
---
# Provider primário
```text
Qwen3.5 2B
```
Runtime:
```text
Ollama
```
Motivos da decisão:
* execução local;
* modelo pequeno;
* suficiente para classificação restrita;
* adequado ao cenário multilíngue;
* elimina custo por chamada no caminho normal;
* não depende de serviço externo durante operação normal.
---
# Primeiro fallback
```text
GPT-OSS 20B
```
Provider:
```text
Groq
```
O Groq somente é chamado quando o provider primário não consegue fornecer uma classificação tecnicamente utilizável.
---
# Segundo fallback
Modelo:
```text
cgpt-web/gpt-5.5
```
Provider:
```text
OmniRoute
```
O OmniRoute somente é utilizado quando:
```text
Ollama falhou
E
Groq falhou
```
---
# Tipo de fallback
O fallback é exclusivamente operacional.
Exemplos de falha que justificam fallback:
```text
conexão recusada
timeout
erro HTTP
provider indisponível
erro de execução
resposta que não pode ser consumida
resposta incompatível com o schema obrigatório
```
---
# O que NÃO provoca fallback
Não executar fallback quando:
```text
modelo retorna text
modelo retorna media
resultado parece improvável
resultado parece ambíguo
modelo parece estar inseguro
outro modelo talvez respondesse diferente
```
Não existe:
* score de confiança;
* votação;
* consenso;
* LLM-as-a-judge;
* segunda opinião;
* comparação de respostas.
Uma resposta válida do provider atual encerra a cadeia.
---
# Execução sequencial
A cadeia deve ser executada sequencialmente.
```text
try Ollama
se resposta válida:
usar resposta
finalizar
se falha operacional:
try Groq
se resposta válida:
usar resposta
finalizar
se falha operacional:
try OmniRoute
```
Não executar providers em paralelo.
---
# Contrato do modelo
Todos os providers devem produzir o mesmo contrato lógico.
```json
{
"content_type": "text",
"media_type": null
}
```
ou:
```json
{
"content_type": "media",
"media_type": "video"
}
```
Valores permitidos:
```text
content_type:
- text
- media
```
```text
media_type:
- video
- image
- images
- embed
- mixed
- null
```
Regra:
```text
text → media_type = null
media → media_type obrigatório
```
---
# Structured output
Sempre que suportado pelo provider, a resposta deve utilizar JSON Schema/structured output nativo.
Independentemente do mecanismo do provider, a aplicação deve validar o resultado antes de aceitá-lo.
Não adicionar parsing tolerante complexo.
O contrato é pequeno e fechado.
Se a resposta não puder ser validada, aquela tentativa é considerada falha do provider e a cadeia avança.
---
# Prompt
O prompt deve ser único e pequeno.
Não criar prompts diferentes por idioma.
A instrução deve explicar apenas:
1. classificar `text` ou `media`;
2. `media` significa que o texto é essencialmente uma introdução ou descrição da mídia;
3. `text` significa que o artigo possui conteúdo jornalístico textual substancial;
4. quando `media`, selecionar `video`, `image`, `images`, `embed` ou `mixed`;
5. retornar exclusivamente o schema estabelecido.
Não solicitar:
* resumo;
* justificativa;
* reasoning;
* confiança;
* evidências;
* tradução;
* keywords.
---
# Configuração do modelo
A classificação deve utilizar comportamento determinístico sempre que o provider permitir.
Não habilitar reasoning desnecessário.
A configuração precisa privilegiar:
```text
baixa variabilidade
structured output
baixa latência
resposta curta
```
---
# Falha dos três providers
Se:
```text
Ollama falhar
Groq falhar
OmniRoute falhar
```
o sistema deve retornar internamente um estado de falha de classificação.
Exemplo lógico:
```json
{
"classification_status": "failed",
"error_message": "media classification providers unavailable"
}
```
Não gerar automaticamente:
```text
text
```
Não gerar automaticamente:
```text
media
```
---
# Destino da falha
O artigo com falha total:
* permanece registrado no JSON principal;
* não passa pelo multimotor;
* não entra no `*_media.json`;
* não interrompe os outros artigos do lote.
Não criar:
```text
*_failed.json
```
Não criar:
* dead-letter queue;
* banco para falhas;
* worker de retry;
* processo assíncrono de recuperação.
---
# Configuração e secrets
As informações específicas de cada provider devem ser externas ao código.
Incluem, quando aplicável:
```text
endpoint
model
API key
timeout
```
Credenciais não podem aparecer:
* no código-fonte;
* no JSON de saída;
* nos logs;
* nas mensagens de erro persistidas.
---
# Observabilidade
Para cada classificação deve ser possível identificar operacionalmente qual caminho foi utilizado:
```text
ollama
groq
omniroute
failed
```
Devem existir logs para:
```text
fallback Ollama → Groq
fallback Groq → OmniRoute
falha final
```
O conteúdo integral do artigo não deve ser logado por padrão.
Não criar um novo sistema de observabilidade especificamente para esta feature.
---
# Testabilidade
A cadeia de providers deve ser testável sem depender dos serviços externos reais.
Os testes devem conseguir simular:
```text
Ollama success
Ollama fail + Groq success
Ollama fail + Groq fail + OmniRoute success
todos falham
provider retorna schema inválido
```
Não é necessário executar chamadas reais aos três providers na suíte normal de CI.
---
# Alternativas consideradas
## Apenas Ollama
Rejeitada.
O sistema é production-ready e precisa continuar funcionando caso o runtime local esteja indisponível.
---
## Ollama + Groq apenas
Rejeitada.
Foi definido um terceiro fallback já disponível através do OmniRoute.
---
## Chamar vários modelos e escolher maioria
Rejeitada.
Não existe requisito que justifique consenso.
Aumentaria:
* custo;
* código;
* latência;
* pontos de falha.
---
## Fallback baseado em confidence
Rejeitada.
Não existe requisito de score de confiança e não deve existir interpretação adicional da resposta.
---
## Modelo grande como principal
Rejeitada.
A tarefa é pequena e fechada.
Qwen3.5 2B atende ao objetivo com custo operacional mínimo.
---
## Criar serviço separado de classificação
Rejeitada.
A classificação pertence ao fluxo atual e não exige um novo deployable.
---
# Consequências positivas
* custo marginal mínimo no caminho principal;
* operação local normalmente;
* alta disponibilidade através de dois fallbacks externos;
* implementação pequena;
* comportamento previsível;
* nenhum acoplamento a framework de agentes;
* fácil teste;
* fácil troca futura de provider através da mesma interface lógica.
---
# Trade-off aceito
Em uma indisponibilidade simultânea de Ollama, Groq e OmniRoute, o artigo não será processado como texto nem mídia.
Essa é uma decisão intencional.
É preferível registrar explicitamente:
```text
classification_failed
```
a mascarar uma falha operacional tomando uma decisão que o sistema não conseguiu realizar.
---
# Invariantes
```text
Ollama é sempre o primeiro provider.
Groq só é chamado após falha operacional do Ollama.
OmniRoute só é chamado após falha operacional de Ollama e Groq.
Resposta válida encerra a cadeia.
Nunca existe votação.
Nunca existe fallback por confiança.
Falha dos três nunca gera classificação presumida.
```
File diff suppressed because it is too large Load Diff