feat: add deterministic content extractor selector engine with F1 consensus
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# Research & Architectural Decisions: Deterministic Content Selection
|
||||
|
||||
**Branch**: `004-deterministic-content-selection` | **Date**: 2026-08-20 | **Spec**: [spec.md](spec.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Text Normalization Pipeline
|
||||
|
||||
### Context
|
||||
Cada extrator (Trafilatura, Newspaper4k e Readability) gera textos com diferentes resíduos de formatação (entidades HTML como `&` ou ` `, links no formato markdown `[texto](url)` ou tags `<a href="...">texto</a>`, variações de quebras de linha e pontuações). A comparação textual para consenso exige uma normalização uniforme, determinística e de alta performance.
|
||||
|
||||
### Decisions
|
||||
1. **Decodificação de entidades HTML**: Utilizar `html.unescape()` da biblioteca padrão do Python.
|
||||
2. **Remoção de imagens Markdown**: Expressão regular `re.compile(r'!\s*\[[^\]]*\]\([^)]*\)')` substituindo imagens Markdown por espaços para descartar marcação de mídia não-textual e evitar falsos consensos com legendas.
|
||||
3. **Preservação de texto de links Markdown**: Expressão regular `re.compile(r'\[([^\]]+)\]\([^)]+\)')` substituindo links Markdown pelo texto âncora `\1`.
|
||||
4. **Remoção de tags HTML**: Expressão regular `re.compile(r'<[^>]+>')` substituindo tags por espaços para evitar fusão acidental de palavras vizinhas.
|
||||
5. **Normalização Unicode**: `unicodedata.normalize('NFKC', text)` para uniformizar caracteres compostos, ligaduras e variantes tipográficas.
|
||||
6. **Conversão para minúsculas**: `.lower()` após NFKC.
|
||||
7. **Colapso de espaços em branco**: `re.sub(r'\s+', ' ', text).strip()`.
|
||||
8. **Tokenização**: Extração de sequências alfanuméricas com `re.findall(r'[\w]+', text, flags=re.UNICODE)`. Pontuações são descartadas naturalmente sem remoção semântica de palavras.
|
||||
|
||||
### Rationale
|
||||
- 100% implementável com módulos padrão do Python (`re`, `unicodedata`, `html`), garantindo portabilidade em qualquer ambiente sem novas dependências externas.
|
||||
- Complexidade linear $O(N)$ no tamanho do texto, com execução em frações de milissegundo por artigo.
|
||||
|
||||
### Alternatives Considered
|
||||
- `BeautifulSoup` para strip de tags: Rejeitado por ser mais lento e desnecessário para textos já extraídos.
|
||||
- `nltk` ou `spacy`: Rejeitados por adicionarem dependências pesadas, download de modelos e lentidão desnecessária para uma tarefa de tokenização alfanumérica pura.
|
||||
|
||||
---
|
||||
|
||||
## 2. 5-Token Shingles & Consensus Metrics
|
||||
|
||||
### Context
|
||||
O algoritmo compara a sobreposição textual entre os candidatos ativos através de janelas deslizantes consecutivas de 5 tokens (shingles).
|
||||
|
||||
### Decisions
|
||||
1. **Geração de Shingles**:
|
||||
- Para um candidato com $T$ tokens ordenados $[t_0, t_1, \dots, t_{T-1}]$:
|
||||
- Se $T \ge 5$: conjunto de tuplas de 5 tokens $\{ (t_i, t_{i+1}, t_{i+2}, t_{i+3}, t_{i+4}) \mid 0 \le i \le T-5 \}$.
|
||||
- Se $1 \le T \le 4$: conjunto contendo uma única tupla com todos os tokens $\{ (t_0, \dots, t_{T-1}) \}$.
|
||||
- Se $T = 0$: conjunto vazio $\emptyset$.
|
||||
2. **Construção do Consenso**:
|
||||
- Para cada shingle único observado nos candidatos ativos, conta-se em quantos candidatos distintos ele aparece.
|
||||
- $\text{Consenso} = \{ s \mid \text{contagem}(s) \ge 2 \}$.
|
||||
3. **Métricas por Candidato Ativo $C$**:
|
||||
- $\text{cobertura}(C) = \frac{|C_{\text{shingles}} \cap \text{Consenso}|}{|\text{Consenso}|}$
|
||||
- $\text{suporte}(C) = \frac{|C_{\text{shingles}} \cap \text{Consenso}|}{|C_{\text{shingles}}|}$
|
||||
- $\text{score}(C) = \frac{2 \times \text{cobertura}(C) \times \text{suporte}(C)}{\text{cobertura}(C) + \text{suporte}(C)}$ (se denominador for zero, $\text{score} = 0.0$).
|
||||
|
||||
### Rationale
|
||||
- A métrica de pontuação $F_1$ penaliza tanto extratores que perderam conteúdo essencial (baixa cobertura) quanto extratores que trouxeram excesso de lixo/boilerplate do site (baixo suporte).
|
||||
- A representação por `set` de tuplas em Python permite operações de intersecção (`&`) com complexidade ótima de tempo $O(|C|)$.
|
||||
|
||||
---
|
||||
|
||||
## 3. Regras de Decisão, Empate Técnico e Desempate Hierárquico
|
||||
|
||||
### Context
|
||||
O sistema precisa garantir uma escolha única e determinística em todas as variações possíveis de entrada.
|
||||
|
||||
### Decisions
|
||||
1. **Formação do Conjunto Ativo**:
|
||||
- Classificação:
|
||||
- `Usável`: `text` é string não vazia após normalização e `error` é `None`/vazio.
|
||||
- `Degradado`: `text` é string não vazia após normalização, mas `error` não é `None`.
|
||||
- `Indisponível`: `text` é nulo, ausente, não-string ou vazio.
|
||||
- Se houver $\ge 1$ Usável $\to$ Ativos = Usáveis.
|
||||
- Senão, se houver $\ge 1$ Degradado $\to$ Ativos = Degradados.
|
||||
- Senão $\to$ Seleciona `newspaper4k` diretamente (Fallback Final).
|
||||
- Se $|\text{Ativos}| = 1 \to$ Seleciona o único candidato ativo imediatamente.
|
||||
2. **Seleção Com Consenso ($|\text{Consenso}| > 0$)**:
|
||||
- Maior score $S_{\max} = \max_{C \in \text{Ativos}} \text{score}(C)$.
|
||||
- Grupo de empate técnico: $\{ C \in \text{Ativos} \mid S_{\max} - \text{score}(C) \le 0.03 + 10^{-9} \}$.
|
||||
- Se grupo tiver 1 candidato $\to$ Seleciona ele.
|
||||
- Se grupo tiver $\ge 2$ candidatos $\to$ Seleciona o candidato com menor $|C_{\text{shingles}}|$ (menor conteúdo excedente).
|
||||
- Se ainda houver empate no número de shingles $\to$ Desempate por prioridade fixa: `newspaper4k` > `readability` > `trafilatura`.
|
||||
3. **Seleção Sem Consenso ($|\text{Consenso}| = 0$)**:
|
||||
- Se $|\text{Ativos}| = 3 \to$ Seleciona candidato com quantidade **mediana** de shingles.
|
||||
- Se $|\text{Ativos}| = 2 \to$ Seleciona candidato com **maior** quantidade de shingles.
|
||||
- Se $|\text{Ativos}| = 1 \to$ Seleciona o único candidato.
|
||||
- Empates na quantidade de shingles $\to$ Prioridade fixa: `newspaper4k` > `readability` > `trafilatura`.
|
||||
|
||||
### Rationale
|
||||
- Total aderência às seções 7.1 a 7.6 do PRD. A tolerância de $10^{-9}$ evita imprecisões de ponto flutuante em comparações `<= 0.03`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Estratégia de I/O Não Destrutiva e Escrita Atômica
|
||||
|
||||
### Context
|
||||
O processamento em lote deve preservar a ordem dos artigos e todos os campos originais do JSON, gravando o resultado sem risco de corrupção de arquivos em caso de interrupção.
|
||||
|
||||
### Decisions
|
||||
1. **Entrada e Saída**:
|
||||
- Nome padrão de saída: `<nome_original_sem_extensão>_selected.json`.
|
||||
- Suporte a argumento opcional de saída `--output / -o`.
|
||||
2. **Gravação Atômica**:
|
||||
- Gravar os dados em um arquivo temporário no mesmo diretório (`<saida>.tmp.<pid>`).
|
||||
- Executar substituição atômica via `os.replace(temp_path, target_path)`.
|
||||
3. **Preservação de Conteúdo**:
|
||||
- Carregar o JSON original em estruturas nativas de dicionário/lista.
|
||||
- Inserir a chave `selected_extractor` diretamente em cada dicionário de artigo.
|
||||
- Se `selected_extractor` já existir na entrada, sobrescrever com o novo valor recalculado.
|
||||
|
||||
### Rationale
|
||||
- Garante integridade absoluta dos dados contra falhas de disco ou encerramentos abruptos.
|
||||
Reference in New Issue
Block a user