refactor: troca PDF+OCR por corpus USFX em domínio público

- corpus/: Almeida (João Ferreira de Almeida) em USFX, domínio público,
  66 livros / 1189 capítulos / 31098 versículos, versionado com SHA-256
- app/corpus.py: parser USFX (texto no tail de <v/>), nomes canônicos dos
  livros, aliases (abreviaturas e numerais romanos), VerseChunk sem página
- indexer: descobre *.xml em CORPUS_DIR, progresso em versículos, lote
  configurável, manifesto incremental por SHA-256
- remove extraction.py, parallel.py, bible_parser.py, Tesseract e PyMuPDF
- campo page removido de Source/SearchResult/StatusResponse e do frontend
- embedder: propriedade name; status reporta o embedder em uso, não o do .env
- create_app constrói o embedder ANTES do store (o default HashEmbedder do
  VectorStore impedia o modelo configurado de ser carregado em produção)
- fallback do modelo agora aparece em /api/status.errors
- torch 2.6.0 + transformers 5.22.1 pinados (transformers 5.x exige torch>=2.6)
This commit is contained in:
omp committed 2026-11-02 11:16:07 -03:00
1 parent 621679cee5
commit 2043d5ddcf
34 files changed
+34561 -1842

No files matched your search

@@ -0,0 +1,221 @@
# Pastor Online Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Entregar um sistema web RAG "Pastor Online" containerizado, que indexa PDFs da Bíblia (incluindo o PDF fornecido, que exige OCR) e responde em chat pastoral ancorado exclusivamente nos versículos recuperados.
**Architecture:** Monólito modular FastAPI + ChromaDB persistente + sentence-transformers multilíngue. Adaptadores HTTP próprios (`httpx`) para Ollama, OpenAI, Groq e Anthropic. Frontend React/Vite servido por Nginx, que fala SSE com o backend. Pipeline de ingestão: PyMuPDF → (fallback Tesseract) → parser bíblico → embeddings → Chroma, com cache de OCR por página e manifesto incremental por SHA-256.
**Tech Stack:** Python 3.11, FastAPI, ChromaDB, sentence-transformers, PyMuPDF, Tesseract, httpx, pytest; React 18, TypeScript, Vite, Vitest, Nginx, Docker Compose.
**Spec:** `docs/superpowers/specs/2026-11-01-pastor-online-design.md`
## Global Constraints
- Python 3.11 (`python:3.11-slim`); React 18 + TypeScript; Docker Compose único.
- PDFs em `./PDF`, montados read-only em `/data/pdfs`; `./pdfs` suportado via `PDF_DIR`.
- Vetores em `/data/vector_db` (volume nomeado `vector_db`), compartilhado com o cache de OCR.
- System prompt pastoral é o da especificação, acrescido de escopo de crise (§10.2 do spec).
- Citações no formato único `Livro Capítulo:Versículo`; validação **antes** de exibir (buffer completo).
- Resposta sem evidência é fixa e **não** chama o LLM.
- Nenhum segredo no bundle do frontend; chaves só no backend via env.
- Reindexação concorrente → 409. `REINDEX_TOKEN` opcional protege `POST /api/reindex`.
- Texto do corpus indexado é dado, nunca instrução (prompt injection).
- OCR: 200 DPI, `tesseract -l por+eng --psm 6`; cache por `(sha256, página)`.
## Review Focus
- PDF com camada textual corrompida (o arquivo real) ⇒ precisa cair no OCR e ainda assim produzir `Gênesis 1:1` correto.
- Pergunta com referência explícita (`João 3:16`) ⇒ resposta ancorada no versículo pedido, não no mais similar.
- Resposta do LLM com citação inventada ⇒ citação removida antes de chegar à tela.
- Índice do PDF ausente/ilegível ⇒ atribuição de livro por cabeçalho da página, sem perder o arquivo inteiro.
- Reindexação com PDF alterado ⇒ só o arquivo alterado é reprocessado; o resto do índice permanece.
- OCR de página que falha ⇒ arquivo fica `partial`, páginas com falha são reprocessadas depois, backend não cai.
---
### Task 1: Fundação do backend — config, modelos e extração de PDF
**Files:**
- Create: `backend/requirements.txt`
- Create: `backend/requirements-dev.txt`
- Create: `backend/pyproject.toml`
- Create: `backend/app/__init__.py`
- Create: `backend/app/config.py`
- Create: `backend/app/models.py`
- Create: `backend/app/extraction.py`
- Test: `backend/tests/test_extraction.py`
**Interfaces:**
- Produces: `Settings` (pydantic-settings, `get_settings() -> Settings`); `PageText(page:int, text:str)`;
`PdfExtractor(cache_dir: Path, dpi:int=200, ocr_lang:str="por+eng", ocr_timeout:int=120)` com
`legible_ratio(text:str)->float`, `extract_page(page, file_hash, page_number)->str`,
`extract_document(path)->Iterator[PageText]`, `ocr_page_image(pixmap)->str`.
- [ ] Step 1: Escrever `backend/tests/test_extraction.py` — `legible_ratio` alto para texto normal,
baixo para controles C0; página legível não chama OCR; página corrompida chama OCR; cache
reutilizado na segunda chamada; erro de OCR não propaga.
- [ ] Step 2: `pytest backend/tests/test_extraction.py -v` → FAIL (módulo inexistente).
- [ ] Step 3: Implementar `config.py`, `models.py`, `extraction.py`.
- [ ] Step 4: `pytest backend/tests/test_extraction.py -v` → PASS.
- [ ] Step 5: Commit.
### Task 2: Parser bíblico
**Files:**
- Create: `backend/app/bible_parser.py`
- Test: `backend/tests/test_bible_parser.py`
**Interfaces:**
- Consumes: `PageText` (Task 1).
- Produces: `VerseChunk(book, chapter, verse, page, file, text)`; `BOOKS` (66 livros canônicos em
português); `parse_book_index(text)->dict[str,int]`; `BibleParser.parse(pages, file_name,
book_starts)->list[VerseChunk]`.
- [ ] Step 1: Escrever testes — atribui livro por faixa de páginas; detecta `[N]`; reconstrói
`Gênesis 1:1`; de-hifeniza `esconde-\nme`; número de versículo isolado vira versículo com texto
na linha seguinte; página de índice/copyright fora da faixa é descartada.
- [ ] Step 2: Rodar → FAIL.
- [ ] Step 3: Implementar `bible_parser.py` (lista canônica + aliases; normalização sem acentos).
- [ ] Step 4: Rodar → PASS.
- [ ] Step 5: Commit.
### Task 3: Embeddings e ChromaDB
**Files:**
- Create: `backend/app/embedding.py`
- Create: `backend/app/vector_store.py`
- Test: `backend/tests/test_vector_store.py`
**Interfaces:**
- Produces: `Embedder` protocol (`encode(texts:list[str])->list[list[float]]`),
`SentenceTransformerEmbedder(model_name)`, `HashEmbedder` (testes, determinístico);
`VectorStore(collection_name, persist_dir)` com `upsert(chunks, vectors)`, `delete_file(file)`,
`query(vector, n_results, min_similarity)->list[SearchResult]`,
`get_by_reference(book, chapter, verse)->list[SearchResult]`, `count()->int`.
- [ ] Step 1: Escrever testes — upsert + query; `delete_file` remove só do arquivo; filtro por
referência; `min_similarity` descarta resultado abaixo do limiar; vetores normalizados.
- [ ] Step 2: Rodar → FAIL.
- [ ] Step 3: Implementar `embedding.py` e `vector_store.py` (Chroma com `hnsw:space=cosine`).
- [ ] Step 4: Rodar → PASS.
- [ ] Step 5: Commit.
### Task 4: Indexador com manifesto e cache de OCR
**Files:**
- Create: `backend/app/indexer.py`
- Test: `backend/tests/test_indexer.py`
**Interfaces:**
- Consumes: `PdfExtractor`, `BibleParser`, `Embedder`, `VectorStore`.
- Produces: `IndexStatus(state,pdfs_total,pdfs_indexed,chunks,last_indexed_at,errors)`;
`Indexer.reindex(force:bool=False)->IndexStatus`; manifesto em `/data/vector_db/manifest.json`
com estados `complete|partial|error`.
- [ ] Step 1: Escrever testes — só o PDF com SHA alterado é reprocessado; manifestos persistidos;
arquivo com página falha fica `partial`; `count()` reflete os chunks.
- [ ] Step 2: Rodar → FAIL.
- [ ] Step 3: Implementar `indexer.py`.
- [ ] Step 4: Rodar → PASS.
- [ ] Step 5: Commit.
### Task 5: Adaptadores de LLM com streaming
**Files:**
- Create: `backend/app/llm.py`
- Test: `backend/tests/test_llm.py`
**Interfaces:**
- Produces: `LLMProvider` protocol (`async stream(messages, system)->AsyncIterator[str]`);
`OllamaProvider`, `OpenAIProvider` (cobre Groq via `base_url`), `AnthropicProvider`, `StubProvider`;
`build_provider(settings)->LLMProvider`; `SYSTEM_PROMPT` com as diretrizes da spec + escopo de crise.
- [ ] Step 1: Escrever testes — cada adaptador converte o formato do provedor em deltas de texto;
`build_provider` escolhe pelo `LLM_PROVIDER`; erro HTTP vira `LLMProviderError` sem expor a chave.
- [ ] Step 2: Rodar → FAIL.
- [ ] Step 3: Implementar `llm.py`.
- [ ] Step 4: Rodar → PASS.
- [ ] Step 5: Commit.
### Task 6: Chat — contexto, validação de citações e resposta pastoral
**Files:**
- Create: `backend/app/chat.py`
- Test: `backend/tests/test_chat.py`
**Interfaces:**
- Produces: `ChatService(settings, store, embedder, provider)` com
`async answer(message, history)->ChatResult`; `ChatResult(text, sources, citations)`;
`NO_EVIDENCE_MESSAGE`; `validate_citations(text, allowed)->tuple[str,list[str]]`;
`extract_reference(text)->tuple[str,int,int]|None`.
- [ ] Step 1: Escrever testes — sem evidência ⇒ texto fixo e provider **não** chamado; citação
não sustentada é removida; sustentada é preservada; pergunta com `João 3:16` usa o chunk
exato; system prompt contém as 4 diretrizes da spec e o escopo de crise.
- [ ] Step 2: Rodar → FAIL.
- [ ] Step 3: Implementar `chat.py`.
- [ ] Step 4: Rodar → PASS.
- [ ] Step 5: Commit.
### Task 7: API FastAPI
**Files:**
- Create: `backend/app/main.py`
- Test: `backend/tests/test_main.py`
**Interfaces:**
- Produces: `GET /api/health`, `GET /api/status`, `POST /api/reindex`, `POST /api/chat` (SSE),
`GET /api/sources`; `create_app(settings=None, services=None)` para testes.
- [ ] Step 1: Escrever testes — status; reindex 202/409/token; chat 422 para mensagem longa;
formato SSE `sources` → `token` → `done`; CORS restrito à origem configurada.
- [ ] Step 2: Rodar → FAIL.
- [ ] Step 3: Implementar `main.py`.
- [ ] Step 4: Rodar → PASS.
- [ ] Step 5: Commit.
### Task 8: Frontend React
**Files:**
- Create: `frontend/package.json`, `frontend/tsconfig.json`, `frontend/vite.config.ts`,
`frontend/index.html`, `frontend/Dockerfile`, `frontend/nginx.conf`
- Create: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/api.ts`,
`frontend/src/styles.css`, `frontend/src/lib/sse.ts`
- Create: `frontend/src/components/ChatPanel.tsx`, `MessageBubble.tsx`, `SourcesAccordion.tsx`, `StatusPanel.tsx`
- Test: `frontend/src/lib/sse.test.ts`, `frontend/src/components/ChatPanel.test.tsx`
**Interfaces:**
- Consumes: API do backend (§8.2 do spec).
- Produces: `parseSSE(stream:ReadableStream<Uint8Array>):AsyncGenerator<SSEEvent>`;
`sendMessage(body):AsyncGenerator<SSEEvent>`.
- [ ] Step 1: Escrever testes Vitest — parser SSE divide eventos; Enter envia, Shift+Enter quebra linha.
- [ ] Step 2: `npm test` → FAIL.
- [ ] Step 3: Implementar o frontend.
- [ ] Step 4: `npm test` → PASS; `npm run build` → PASS.
- [ ] Step 5: Commit.
### Task 9: Docker Compose, env e README
**Files:**
- Create: `docker-compose.yml`, `.env.example`, `.dockerignore`, `README.md`
- Modify: `backend/Dockerfile`
**Interfaces:**
- Produces: serviços `backend`, `frontend`, `ollama`, `ollama-pull`; volumes `vector_db`, `ollama`;
`COMPOSE_PROFILES=local` padrão.
- [ ] Step 1: Escrever `README.md` com preparación de `.env`, volume de PDFs, comandos de
start, provedores, troubleshooting de OCR e de Ollama.
- [ ] Step 2: `docker compose config` → exit 0.
- [ ] Step 3: `docker compose build` → exit 0.
- [ ] Step 4: Commit.
### Task 10: Verificação de integração
- [ ] Step 1: `docker compose up -d` e `GET /api/health` → 200.
- [ ] Step 2: `POST /api/reindex` → 202; `/api/status` → `indexing` → `ready`.
- [ ] Step 3: `POST /api/chat` com `LLM_PROVIDER=stub` → `sources` + `token` + `done`.
- [ ] Step 4: Abrir o frontend no navegador e verificar chat, citações, accordion e painel de status.
- [ ] Step 5: `pytest backend/tests -v` → 0 falhas; `npm test` → 0 falhas.
@@ -0,0 +1,428 @@
# Pastor Online — Design do Sistema RAG
**Data:** 2026-11-01
**Status:** Proposed
## 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.