Files
pastor-online/docs/superpowers/specs/2026-11-01-pastor-online-design.md
T
omp 8f5e098d8f feat: stemming português no BM25 e índice lexical sobre o versículo
Dois defeitos medidos no índice real (31.098 versículos), ambos na metade
lexical da busca híbrida:

1. Sem normalização morfológica, 'amar' da pergunta nunca casava com 'amai' ou
   'amarás' do versículo. Para 'o que Jesus disse sobre amar os inimigos?',
   Mateus 5:44 ficava em 434º no BM25 enquanto Marcos 12:34 liderava por casar
   apenas 'jesus'+'disse'. keyword.py ganha um stemmer de sufixos em etapas
   (estilo RSLP, sem dependência externa) com guarda de comprimento mínimo do
   radical — sem ela 'deus' viraria 'd' e 'mar' viraria 'm'. Rank de Mt 5:44: 12º.

2. O BM25 indexava a janela de contexto. Os dois lados querem granularidades
   opostas: a janela dá tema ao embedding, mas multiplica os termos incidentais
   que o BM25 pontua. Indexando o versículo isolado, Mt 5:44 vai a 1º e os
   achados só-lexicais deixam de ser aleatórios ('ansioso e não consigo dormir'
   -> Jó 3:13 'teria dormido'; 'não sei que decisão tomar' -> Joel 3:14 'vale da
   decisão').

Colisão conhecida do stemming: 'luto' e 'lutavam' reduzem ao mesmo radical.
2026-11-02 12:39:54 -03:00

465 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Pastor Online — Design do Sistema RAG
**Data:** 2026-11-01
**Status:** Proposed
> ## Atualização (2026-11-02): ingestão por corpus estruturado
>
> As seções que descrevem **PDF → OCR → parser de índice → reconstrução de
> capítulo/versículo** foram substituídas. O resto do desenho (FastAPI, ChromaDB,
> validação de citações, SSE, Docker Compose, provedores de LLM) segue válido.
>
> **O que mudou:** a entrada passou a ser XML [USFX](https://ebible.org/usfx/) em
> domínio público (`corpus/por-almeida.usfx.xml`, Almeida, 66 livros, 31.098
> versículos), versionado no repositório.
>
> **Por quê:** o PDF exigia OCR das 3.364 páginas e a camada textual é corrompida,
> então cada referência era *reconstruída* a partir de texto ruidoso. Com o corpus
> estruturado a referência é a coordenada de onde o texto foi lido. Medido:
> indexação de 31.098 versículos em ~30 min contra 17–67 min de OCR, sem Tesseract,
> sem PyMuPDF e sem cache de páginas.
>
> **Duas falhas encontradas no caminho**, ambas silenciadas por projeto anterior:
> 1. `create_app` criava o `VectorStore` antes do embedder; como o store default
> para `HashEmbedder`, o modelo configurado **nunca** era carregado em produção
> e a busca rodava sem semântica nenhuma.
> 2. `transformers` não estava pinado: a versão 5.x exige `torch>=2.6`, e o
> Dockerfile pinava 2.5.1 — o `sentence-transformers` desativava o PyTorch e o
> fallback silencioso caía no mesmo `HashEmbedder`.
>
> `/api/status` agora reporta o embedder **em uso** (não o do `.env`) e qualquer
> fallback aparece em `errors`.
>
> **Busca:** passou a híbrida (vetorial + BM25, fusão por RRF) e cada chunk indexa o
> versículo com uma janela de vizinhos. Medido em 5.703 versículos reais: a busca
> puramente vetorial não liga "estou com medo do futuro" a "não temas, porque eu sou
> contigo" (similaridade 0,19, no nível de um par sem relação), enquanto acerta
> "amar os inimigos" → Mateus 5:44 (0,683). Os dois lados erram de modos diferentes.
> `intfloat/multilingual-e5-small` foi descartado apesar de treinado para
> recuperação: a faixa de similaridade dele tem 0,02 de largura (0,853–0,873), o que
> inutilizaria o piso de evidência do qual a garantia de fidelidade depende.
## 1. Intenção
O Pastor Online é uma aplicação web local, containerizada, que lê PDFs da Bíblia,
indexa seu conteúdo em um banco vetorial e responde perguntas em linguagem natural
com tom pastoral, fundamentado **exclusivamente** nos trechos recuperados do corpus
bíblico indexado.
Motivo: oferecer acompanhamento pastoral consistente, auditável e ancorado no texto
— sem depender da memória ou da opinião do modelo de linguagem.
## 2. Estado atual do repositório
- Repositório vazio, exceto pelo diretório `PDF/` contendo `biblia.pdf` (8,1 MB, 3.364 páginas).
- Não há código, testes, Dockerfiles ou configuração Git. O diretório não é um repositório Git.
- **Fato técnico decisivo:** a camada textual do PDF não possui mapeamento Unicode.
`pdftotext` e PyMuPDF devolvem sequências de controle (`\x01`–`\x1f`) em vez de letras.
A inspeção de páginas amostradas (6, 7, 101, 501, 1501, 3001) confirmou razão de
caracteres alfanuméricos legíveis < 2% em todas elas. O PDF é **visualmente correto**
e precisa de OCR para ser utilizável.
- **Edição identificada:** Almeida Revista e Corrigida (publicação L.C.C./eBooksBrasil, 2006),
66 livros canônicos, sem deuterocanônicos. Termina em Apocalipse 22:21, seguido de uma
página de copyright a ser descartada da indexação.
- **Estrutura validada por OCR:** página 3–4 traz o índice com a página inicial de cada
livro (`Génesis: 6`, `Êxodo: 168`, … `Apocalipse: 3314`); cada página de texto traz o nome
do livro no cabeçalho, o capítulo como `[N]` e os versículos numerados no início da linha.
- **Medição de desempenho:** OCR a 200 DPI com `tesseract -l por+eng --psm 6` levou ~1,2 s
por página e reproduziu o texto com acentos corretos. Com 4 workers, a indexação inicial
de 3.364 páginas fica em torno de 17 minutos; com cache de OCR, reindexações são quase
instantâneas.
## 3. Objetivos
1. Ingestão automática de PDFs em `./PDF` (montados como `/data/pdfs`) na inicialização
do backend, com reindexação incremental por hash de arquivo.
2. Busca semântica multilíngue em CPU, com metadados de livro, capítulo e versículo.
3. Chat com streaming, Markdown, citações destacadas e painel retrátil de trechos consultados.
4. Painel de status com PDFs processados e vetores indexados.
5. Suporte a quatro provedores de LLM por variável de ambiente: Ollama, OpenAI, Groq e Anthropic.
6. Execução por `docker compose up --build`, com volumes persistentes.
## 4. Não objetivos
- Autenticação, multiusuário, histórico persistente de conversas ou papéis administrativos.
- Bancos vetoriais distribuídos, observabilidade centralizada ou telemetria de usuários.
- Índice de busca full-text paralelo ao vetorial.
- Suporte a documentos além de PDFs de Bíblia, nem a múltiplos corpus configurados.
- Aplicação móvel nativa.
- Avaliação teológica automática das respostas.
## 5. Antiobjetivos
- **Nunca** permitir que o LLM responda a partir de conhecimento próprio quando o corpus
não sustentar a resposta.
- **Nunca** exibir uma citação cujo livro/capítulo/versículo não exista nos metadados
recuperados.
- **Nunca** expor chaves de API em respostas, logs ou no bundle do frontend.
- **Nunca** aceitar PDFs arbitrários pela rede: a ingestão lê apenas do diretório montado.
## 6. Restrições e decisões de ambiente
| Item | Decisão |
|---|---|
| Runtime backend | Python 3.11+ (imagem `python:3.11-slim`) |
| API | FastAPI + Uvicorn |
| Banco vetorial | ChromaDB persistente (`/data/vector_db`) |
| Embeddings | `sentence-transformers`, `paraphrase-multilingual-MiniLM-L12-v2` (CPU) |
| Extração | PyMuPDF (camada textual) + Tesseract OCR (`por+eng`) como fallback |
| Frontend | React 18 + Vite + TypeScript, CSS com tokens de tema, Nginx servindo o build |
| Orquestração LLM | Adaptadores HTTP próprios sobre `httpx` (sem LlamaIndex/LangChain) |
| PDFs | `./PDF` montado em `/data/pdfs`; `./pdfs` suportado via `PDF_DIR` |
| Volumes | `vector_db` (Chroma + cache de OCR), `ollama` (modelos) |
| Orquestração | Docker Compose; serviço `ollama` sob profile opcional `ollama` |
### 6.1 Requisito de CPU e tempo de indexação
OCR de 3.364 páginas é o gargalo real. Medidas de projeto:
- Renderização da página a 200 DPI + Tesseract em português.
- Paralelismo por processo com `OCR_WORKERS` (padrão: `min(4, cpu_count)`).
- **Cache de OCR por página** em `/data/vector_db/ocr_cache/<sha256-do-arquivo>/<página>.txt`,
invalidado apenas quando o hash do PDF muda. Sem esse cache, cada reindexação
repetiria horas de OCR.
- Embeddings calculados em lotes (`EMBED_BATCH_SIZE`, padrão 32).
## 7. Abordagens consideradas
### A. Monólito modular com adaptadores próprios — **escolhida**
FastAPI + ChromaDB + sentence-transformers + adaptadores HTTP diretos para os quatro
provedores de LLM.
- **Prós:** menor árvore de dependências; controle explícito do chunking, do prompt, do
streaming SSE e das citações; comportamento previsível em CPU; menos superfície de
abstração indireta.
- **Contras:** o roteamento entre provedores e o parser de respostas são mantidos no projeto.
### B. LlamaIndex sobre FastAPI
- **Prós:** `VectorStoreIndex`, `SentenceSplitter` e readers reducesem código próprio.
- **Contras:** ciclo de vida de serialização do LlamaIndex; streaming e citações ainda
exigiriam código específico; a ingestão precisaria de reader customizado para o
fallback de OCR de qualquer forma.
### C. LangChain sobre FastAPI
- **Prós:** ecossistema largo de provedores e retrievers.
- **Contras:** maior peso, cadeia de dependências mais difícil de auditar e risco de
fallback silencioso em respostas de LLM — incompatível com a exigência de fidelidade.
**Justificativa:** A opção B não elimina o trabalho específico (OCR, parser de versículos,
citações), enquanto a C introduz risco no requisito mais crítico. A opção A entrega
o menor caminho verificável entre as fontes e a resposta.
## 8. Arquitetura
```mermaid
flowchart LR
subgraph Host
PDF["./PDF/*.pdf"]
end
subgraph Compose
BE["Backend FastAPI<br/>/data/pdfs · /data/vector_db"]
OCR["PyMuPDF + Tesseract"]
EMB["sentence-transformers"]
CHROMA[("ChromaDB")]
LLM["Adaptadores LLM<br/>httpx"]
FE["Frontend React/Nginx"]
end
subgraph Optional
OL["Ollama<br/>profile: ollama"]
end
PDF -->|bind mount ro| OCR
OCR -->|versículos| EMB
EMB -->|vetores + metadata| CHROMA
CHROMA -->|top-k trechos| BE
BE -->|stream SSE| FE
BE -->|HTTP| LLM
LLM -.-> OL
LLM -.-> OpenAI
LLM -.-> Groq
LLM -.-> Anthropic
FE -->|usuário| BE
```
### 8.1 Componentes do backend
| Arquivo | Responsabilidade |
|---|---|
| `backend/app/config.py` | Settings via `pydantic-settings`; leitura de `.env` |
| `backend/app/models.py` | Schemas Pydantic de request/response e de(chunk) fonte |
| `backend/app/extraction.py` | Extração de texto por página: camada textual + fallback OCR + cache |
| `backend/app/bible_parser.py` | Reconstrução de livro/capítulo/versículo e segmentação em versículos |
| `backend/app/embedding.py` | Wrapper do `sentence-transformers`; normalização e lotes |
| `backend/app/vector_store.py` | Cliente Chroma persistente; upsert, query e contagem |
| `backend/app/indexer.py` | Manifesto incremental por SHA-256; orquestra extração → parser → embeddings → Chroma |
| `backend/app/llm.py` | Interface `LLMProvider` + adaptadores Ollama/OpenAI/Groq/Anthropic com streaming |
| `backend/app/chat.py` | Montagem do contexto, prompt pastoral, validação de citações e fallback sem evidência |
| `backend/app/main.py` | Rotas FastAPI, lifespan, CORS, tratamento de erros |
| `backend/tests/` | Testes pytest do backend |
### 8.2 Contratos de API
| Rota | Método | Contrato |
|---|---|---|
| `/api/health` | GET | `{"status":"ok","llm_provider":"ollama","indexing":"ready"}` |
| `/api/status` | GET | `{"state":"ready|indexing|error","pdfs_total":int,"pdfs_indexed":int,"chunks":int,"model":str,"last_indexed_at":str|null,"errors":[str]}` |
| `/api/reindex` | POST | Inicia reindexação; retorna `202 {"state":"indexing"}`. Sem `REINDEX_TOKEN` configurado, aberto. Com token, exige header `X-Reindex-Token`. |
| `/api/chat` | POST | Body `{"message":str,"history":[{"role","content"}]}`. Responde `text/event-stream`. |
| `/api/sources` | GET | `{"sources":[{...}]}` — últimos trechos recuperados (depuração/inspeção) |
Fluxo SSE de `/api/chat`:
```
event: sources
data: {"sources":[{"reference":"Gênesis 1:1","text":"...","page":6,"file":"biblia.pdf"}]}
event: token
data: {"text":"No início"}
event: done
data: {"citations":["Gênesis 1:1"]}
event: error
data: {"message":"..."}
```
O frontend usa `fetch` + `ReadableStream` porque `EventSource` não aceita `POST`.
### 8.3 Frontend
- `frontend/src/App.tsx`: layout, tema (claro/escuro persistido em `localStorage`).
- `frontend/src/components/ChatPanel.tsx`: lista de mensagens, `textarea` com Enter para
enviar e Shift+Enter para nova linha.
- `frontend/src/components/MessageBubble.tsx`: Markdown via `react-markdown` + `remark-gfm`.
- `frontend/src/components/SourcesAccordion.tsx`: `<details>` retrátil com os trechos consultados.
- `frontend/src/components/StatusPanel.tsx`: PDFs lidos, vetores indexados, botão de reindexação.
- `frontend/src/lib/sse.ts`: parser de stream SSE (testável isoladamente).
- `frontend/src/api.ts`: cliente HTTP do backend.
- Estilos: CSS único com variáveis (`--fg`, `--muted`, `--surface`, `--accent`, `--border`),
tema via `data-theme` em `<html>`; layout responsivo com painel lateral colapsável abaixo
de 900 px.
## 9. Pipeline de ingestão
1. **Descoberta:** `*.pdf` em `PDF_DIR`, ordenados por nome.
2. **Manifesto:** `/data/vector_db/manifest.json` mapeia `nome_do_arquivo → {sha256, chunks, indexed_at}`.
Arquivo ausente do manifesto ou com SHA diferente ⇒ reindexar **apenas** esse arquivo.
3. **Extração por página** (`extraction.py`):
- Tenta `page.get_text("text")` do PyMuPDF.
- Calcula `legible_ratio` = caracteres alfanuméricos imprimíveis / caracteres não-branco.
- Se `legible_ratio < 0.35` ⇒ considera a camada textual corrompida, rasteriza a página
(200 DPI) e aplica Tesseract (`por+eng`) via subprocess com timeout de 120 s.
- Resultado é cacheado por `(sha256, página)`; páginas em cache evitam OCR e extração.
4. **Parser bíblico** (`bible_parser.py`):
- **Atribuição de livro por faixa de páginas:** o índice das páginas 3–4 do PDF fornece a
página inicial de cada um dos 66 livros (`Génesis: 6` … `Apocalipse: 3314`). Esse índice é
OCR'd uma única vez e memoizado em `/data/vector_db/book_index.json`. Um chunk na página
*p* pertence ao último livro cujo início é ≤ *p*. Isso é determinístico e independe de
detecção de título por estilo — muito mais robusto que casar caixa-alta.
- Fallback: se o índice não puder ser lido, detectar o livro pela linha de cabeçalho da
página (nome do livro repetido no topo de cada página) ou por título isolado em caixa-alta.
- Capítulos: linha isolada `[N]`.
- Versículos: linha iniciada por número seguido de texto; a numeração reinicia a cada
capítulo. OCR pode unir o número ao texto ou separá-los em linhas distintas — ambos
os formatos são aceitos.
- De-hifenização: palavras hifenizadas no fim da linha (`esconde-\nme`) são religadas,
preservando hífens léxicos (`mas o Espírito`).
- Cada versículo vira um chunk com metadados `{book, chapter, verse, page, file, text}`.
- Páginas sem numeração reconhecível (ex.: continuação de versículo longo) são anexadas ao
capítulo corrente como continuação do último versículo.
- Páginas fora da faixa bíblica (índice, copyright) são descartadas por não pertencerem a
nenhum livro segundo o índice.
5. **Embeddings:** lotes via `sentence-transformers`, vetores normalizados.
6. **Persistência:** `collection.upsert` com IDs determinísticos `sha1(file|book|chapter|verse)`.
Ao reindexar um arquivo, seus IDs antigos são apagados antes do upsert (idempotência).
7. **Estado:** `/api/status` reflete contagens durante e após o processo.
### 9.2 Ciclo de vida da indexação e falhas parciais
- O backend **sobe imediatamente** e serve `/api/status` com `state="indexing"`. Não bloqueia
o startup: uma indexação inicial de ~17 min não pode impedir a aplicação de abrir.
- A indexação roda como `BackgroundTask`; o chat responde durante ela, mas enquanto
`state="indexing"` o backend informa no stream que o índice ainda está em construção
(fonte indisponível → caminho “sem evidência”), em vez de fingir que não há evidência.
- O manifesto distingue estados por arquivo: `indexing`, `complete`, `partial`, `error`.
- `complete`: todas as páginas do arquivo foram extraídas e parseadas.
- `partial`: algumas páginas falharam (OCR/parse). O arquivo **é** indexado com o que
deu certo, mas o estado e a lista de páginas com falha ficam registrados e visíveis em
`/api/status`. Um `partial` **não** é considerado “indexado com sucesso” no manifesto:
a próxima reindexação reprocessa apenas as páginas que faltam.
- `error`: nenhuma página pôde ser processada; o arquivo é ignorado e o erro aparece no status.
- Reindexação interrompida (restart do container) retoma do cache de OCR: as páginas já
extraídas são reaproveitadas, e só as faltantes são reprocessadas.
- Reindexação concorrente é rejeitada com 409 (lock não-bloqueante).
### 9.3 Detecção de camada textual corrompida
Critério: `legible_ratio < 0.35` **ou** presença de caracteres de controle C0 além de
`\t`, `\n`, `\r` acima de 1% do texto. Ambos foram validados contra o PDF fornecido.
Texto extraído com alta densidade de controles é ilegível para o usuário e para o modelo;
usá-lo silenciosamente produziria respostas sem sentido — o fallback de OCR é obrigatório.
## 10. Busca e geração
- **Consulta semântica:** embedding da pergunta; Chroma `query` com `n_results=6`;
`MIN_SIMILARITY=0.35` (configurável). Trechos abaixo do limiar são descartados.
- **Consulta por referência explícita:** perguntas como “o que diz João 3:16” são atendidas por
um caminho determinístico: o backend detecta a referência na mensagem, busca o chunk por
metadados (`book` + `chapter` + `verse` no Chroma) e usa esse trecho como evidência
principal, independentemente da similaridade semântica. Sem isso, uma pergunta por
referência dependeria de o embedding posicioná-la acima do limiar — frágil.
- **Sem evidência:** o backend responde com texto fixo em português, sem chamar o LLM:
> "O texto bíblico indexado não aborda diretamente esse assunto. Posso meditar com você
> os princípios de amor e sabedoria presentes na Palavra, se quiser explorar um deles."
- **Contexto:** os trechos recuperados entram delimitados e numerados
(`[FONTE 1] Gênesis 1:1 ...`), instruindo o modelo a tratá-los como dados, nunca como instruções.
- **System prompt:** o prompt fornecido na especificação, acrescido de: responder apenas com
base no contexto; citar livro, capítulo e versículo conforme `[FONTE n]`; não inventar
referências; não criar doutrina; tom pastoral empático.
- **Formato de citação (único):** `Livro Capítulo:Versículo` em prosa, por exemplo
`Gênesis 1:1` ou `Salmos 23:1-3`. O marcador `[FONTE n]` existe **apenas** no contexto
enviado ao LLM e nunca aparece na resposta exibida. O backend extrai as referências do
texto final com o mesmo padrão, de modo que resposta, validação e UI usam uma única forma.
- **Validação de citações (servidor):** extrai referências no padrão
`Livro Capítulo:Versículo` (incluindo variantes `1:1-3`), confere se cada uma existe na
lista de referências recuperadas e remove do texto final apenas as referências não
sustentadas. Se a resposta não citar nenhuma fonte válida, o backend emite um aviso
padronizado de que a resposta não foi ancorada e as fontes são exibidas separadamente.
- **Streaming e validação:** ver §10.1 — a política de ordem entre tokens e validação é
uma decisão de produto registrada abaixo.
### 10.1 Ordem entre streaming e validação de citações
**Decisão do usuário: validar antes de exibir (buffer completo).**
A resposta é gerada integralmente pelo LLM, as citações são validadas contra as fontes
recuperadas, e só então o texto é transmitido ao navegador. Nenhuma citação inválida
alcança a tela. O streaming interno do LLM permanece ativo (tokens são consumidos conforme
chegam), mas o frontend exibe a resposta concluída, com indicador de “preparando resposta”
enquanto aguarda.
O contrato SSE permanece o mesmo (`sources` → `token` → `done`), porém o evento `token`
transporta a resposta já validada — em vez de um token por vez, pode ser um único evento
com o texto completo. Isso preserva o formato do frontend e a possibilidade de exibir
progresso, sem sacrificar a fidelidade.
O custo é perceptível: o usuário espera o texto completo antes de vê-lo. É o preço aceito
pela garantia de fidelidade, e é explícito na interface.
**Semântica de remoção:** uma referência não sustentada é removida do texto; o texto
resultante é enviado como está. Se a remoção deixar a resposta sem qualquer citação
sustentada, o backend acrescenta ao final uma nota padrão:
> "Observação: não foi possível ancorar esta resposta em um versículo específico do
> texto indexado. Os trechos consultados estão listados abaixo."
### 10.2 Escopo pastoral
**Decisão do usuário: reflexão bíblica com encaminhamento em situações de crise.**
O system prompt delimita o escopo:
- Oferece consolo, oração, reflexão e orientação ancorada nos versículos recuperados.
- **Não** emite diagnósticos, prescrições médicas, aconselhamento jurídico ou
orientações financeiras.
- Em indícios de crise (desespero, autodano, dependência, crise de saúde mental), a
resposta Bárbara exorta o cuidado com segurança imediata, sugere procurar ajuda
profissional e de confiança, e oferece versículos de conforto — sem substituir o cuidado
humano.
- Sinais explícitos de risco iminente geram orientação para os serviços de emergência
locais, sem inventar números de telefone: o texto remete aos serviços públicos de
emergência do país do usuário, quando conhecido, ou a “emergência local”.
### 10.3 Continuidade de conversa
O histórico entra no prompt (últimas 10 trocas) para dar contexto. A **consulta** usa a
mensagem atual acrescida do histórico apenas quando a pergunta atual é claramente
contextual (começa com pronome como “e no Antigo Testamento?”, “o que mais?”), caso em que
a pergunta anterior é reescrita junto. A reescrita é feita pelo próprio LLM em uma chamada
curta e não-streaming antes da busca; se a chamada falhar, usa-se a mensagem atual isolada.
O chat não persiste conversas entre recargas da página (não objetivo).
## 11. Threat model
Escopo: ingestão de PDFs não confiáveis, mensagens de usuário e chamada a provedores externos.
| Ativo / fronteira | Ameaça | Mitigação |
|---|---|---|
| PDF de terceiros → parser | PDF malformado causando crash/exaustão | Tratar cada PDF/página em `try/except`; validar magic bytes `%PDF`; limite de tamanho por arquivo; timeout por página no OCR; erros por arquivo registrados, sem derrubar o backend |
| Conteúdo do PDF → prompt do LLM | Prompt injection em texto bíblico/indexado | Contexto delimitado e rotulado como dado; system prompt declara que conteúdo recuperado é evidência, nunca instrução; validação de citações no servidor |
| Mensagem do usuário → LLM | Injection do usuário, bypass de fidelidade | Mesma delimitação; histórico limitado às últimas 10 trocas; mensagens acima de 4.000 caracteres rejeitadas com 422 |
| Backend → API externa | Vazamento de chave | Chaves só em variáveis de ambiente do backend; `logging` com filtro que mascara Authorization; erros externos retornam mensagem genérica; chave nunca vai ao bundle do navegador |
| Usuário → endpoint de reindexação | Reindexação repetida = DoS | Endpoint POST; `REINDEX_TOKEN` opcional; reindexação concorrente é rejeitada (lock); idempotência por hash evita retrabalho |
| Backend → navegador | CORS amplo demais | `CORS_ORIGINS` explícito; default `http://localhost:8080` |
| Container → host | Escrita fora do volume | PDF montado **read-only**; escrita restrita a `/data` |
| LLM indisponível | Resposta genérica/inventada | Erro 503 explícito; nunca fallback silencioso para outro provedor ou conhecimento próprio do modelo |
Categorias de risco avaliadas segundo OWASP Top 10 e MITRE ATT&CK. Riscos aceitos:
aplicação local sem autenticação (o Compose não expõe portas fora da rede do projeto,
exceto o frontend), e ausência de criptografia em repouso no volume local.
## 12. Testes
**Backend (pytest):**
- `extraction`: página com texto legível não aciona OCR; página corrompida aciona OCR;
resultado é cacheado e reaproveitado; erro de OCR não aborta o arquivo.
- `bible_parser`: atribui livro por faixa de páginas do índice; detecta capítulo `[N]`;
reconstrói `Gênesis 1:1`; de-hifeniza; texto sem numeração é anexado ao capítulo corrente;
páginas de índice/copyright são descartadas.
- `indexer`: reindexa apenas arquivo cujo SHA mudou; ids antigos são removidos; manifesto
persistido; arquivo parcialmente falho fica como `partial` e é reprocessado depois.
- `chat`: sem evidência ⇒ resposta fixa sem chamar LLM; citação não sustentada é removida;
citação sustentada é preservada; system prompt contém as diretrizes e o escopo pastoral;
pergunta com referência explícita (`João 3:16`) busca pelo metadado.
- `llm`: cada adaptador converte streaming do provedor em delta de texto (transporte falso).
- `main`: status, reindexação (409 concorrente, token), validação de entrada, formato do SSE.
**Frontend (Vitest):**
- `sse.ts`: parser de eventos `sources`/`token`/`done`/`error`.
- `ChatPanel`: Enter envia, Shift+Enter quebra linha.
- `SourcesAccordion`: renderiza e abre os trechos consultados.
**Verificação de integração:**
- `docker compose up --build`; `GET /api/health`; `POST /api/reindex`; `POST /api/chat`
com um provedor fake registrado em `LLM_PROVIDER=fake` apenas para o smoke test; conferir
no navegador a renderização da resposta, das citações e do painel de status.
**Verificação do corpus (calibração, não gate de build):**
- Amostra de 30 páginas OCR'd é conferida contra a renderização visual do PDF.
- O número de versículos por livro extraído é comparado com a contagem canônica
(31.102 versículos na Almeida). Divergências acima de 2% por livro disparam revisão do parser.
- 5 perguntas de referência com resposta esperada conhecida (ex.: “Quem disse ‘no princípio criou
Deus os céus e a terra’?” ⇒ Gênesis 1:1) validam o caminho determinístico de citação.
## 13. Decisões tomadas
### 13.1 Decididas pelo usuário
- **Extração:** camada textual + fallback OCR.
- **Local dos PDFs:** manter `./PDF`, montado em `/data/pdfs`.
- **Arquitetura:** monólito modular com adaptadores próprios.
- **Citações e streaming:** validar antes de exibir (buffer completo); nenhuma citação
não sustentada chega à tela.
- **Escopo pastoral:** reflexão bíblica com encaminhamento a profissionais e serviços de
emergência em situações de crise; sem diagnóstico, prescrição, aconselhamento jurídico
ou financeiro.
### 13.2 Decididas pelo projetor
- **Modelo de embeddings:** `paraphrase-multilingual-MiniLM-L12-v2`, por operação multilíngue
em CPU; `all-MiniLM-L6-v2` fica aceito via `EMBEDDING_MODEL` para quem preferir menor consumo.
- **Idioma do OCR:** `por+eng`.
- **Atribuição de livros:** pelo índice de páginas do próprio PDF, com fallback por cabeçalho.
- **Canon:** 66 livros, sem deuterocanônicos, conforme a edição Almeida do PDF fornecido.
- **Ciclo de vida:** startup não bloqueante; indexação em background; manifesto com estados
`complete` / `partial` / `error`.
- **Provedor LLM padrão:** `ollama` com `qwen2.5:1.5b`; o serviço Ollama no Compose usa
profile opcional `ollama` e um serviço `ollama-pull` que baixa o modelo na primeira
execução. OpenAI, Groq e Anthropic funcionam com apenas `LLM_PROVIDER` + chave.
- **Sem `git init`:** o diretório não é um repositório Git e o usuário não solicitou versionamento;
o documento de design é gravado, não commitado.