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.
465 lines
27 KiB
Markdown
465 lines
27 KiB
Markdown
# 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.
|