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.