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:
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.
|
||||
Reference in new issue
Block a user