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

+45 -52
View File
@@ -3,8 +3,8 @@
Rotas:
* `GET /api/health` — verificação de saúde
* `GET /api/status` — estado da indexação (PDFs lidos e vetores indexados)
* `POST /api/reindex` — força a reindexação dos PDFs
* `GET /api/status` — estado da indexação (fontes lidas e vetores indexados)
* `POST /api/reindex` — força a reindexação do corpus
* `POST /api/chat` — chat em streaming (SSE)
* `GET /api/sources` — últimos trechos recuperados (inspeção)
"""
@@ -22,11 +22,9 @@ from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from .bible_parser import BibleParser
from .chat import ChatService
from .config import Settings, get_settings
from .embedding import Embedder, HashEmbedder, SentenceTransformerEmbedder
from .extraction import PdfExtractor
from .indexer import Indexer
from .llm import LLMProvider, LLMProviderError, build_provider
from .models import (
@@ -53,29 +51,38 @@ class Services:
indexer: Indexer
chat: ChatService
last_sources: list[Source]
embedder_warning: str | None = None
def _build_embedder(settings: Settings) -> Embedder:
def _build_embedder(settings: Settings) -> tuple[Embedder, str | None]:
"""Cria o embedder conforme o modelo configurado.
`hash` e modelos desconhecidos usam o embedder determinístico; os demais
usam sentence-transformers.
Devolve `(embedder, aviso)`. O aviso existe porque o fallback é silencioso por
natureza — sem ele, `/api/status` reportaria o nome do modelo configurado
enquanto a busca roda sem semântica nenhuma, e ninguém perceberia.
"""
model = settings.embedding_model.strip().lower()
if model in ("", "hash", "hash-test", "none"):
return HashEmbedder()
return HashEmbedder(), None
try:
return SentenceTransformerEmbedder(
settings.embedding_model, batch_size=settings.embedding_batch_size
return (
SentenceTransformerEmbedder(
settings.embedding_model, batch_size=settings.embedding_batch_size
),
None,
)
except Exception as exc: # pragma: no cover - falha de download do modelo
except Exception as exc:
logger.error(
"Não foi possível carregar o modelo de embedding %s: %s. "
"Usando embedder determinístico.",
settings.embedding_model,
exc,
)
return HashEmbedder()
return HashEmbedder(), (
f"O modelo de embedding '{settings.embedding_model}' não carregou ({exc}). "
"A busca está rodando com um embedder determinístico SEM semântica: "
"os resultados não são confiáveis. Corrija as dependências e reindexe."
)
def create_app(
@@ -84,36 +91,31 @@ def create_app(
embedder: Embedder | None = None,
provider: LLMProvider | None = None,
indexer: Indexer | None = None,
embedder_warning: str | None = None,
) -> FastAPI:
"""Cria a aplicação FastAPI. As dependências podem ser injetadas nos testes."""
settings = settings or get_settings()
# A ordem importa: o embedder precisa existir antes do store. `VectorStore`
# default para `HashEmbedder` quando nasce sem um, e herdar o embedder dele
# faria a aplicação nunca carregar o modelo configurado — a busca passaria a
# rodar sem semântica enquanto `/api/status` anunciaria o nome do modelo.
if embedder is None and store is not None:
embedder = getattr(store, "embedder", None)
if embedder is None:
embedder, built_warning = _build_embedder(settings)
embedder_warning = embedder_warning or built_warning
if store is None:
store = VectorStore(
persist_dir=settings.vector_db_dir,
collection_name=settings.collection_name,
embedder=embedder,
)
if embedder is None:
embedder = getattr(store, "embedder", None) or _build_embedder(settings)
if provider is None:
provider = build_provider(settings)
if indexer is None:
extractor = PdfExtractor(
cache_dir=settings.ocr_cache_dir,
dpi=settings.ocr_dpi,
ocr_lang=settings.ocr_language,
ocr_timeout=settings.ocr_timeout,
min_legible_ratio=settings.min_legible_ratio,
)
indexer = Indexer(
settings=settings,
extractor=extractor,
parser=BibleParser(),
embedder=embedder,
store=store,
)
indexer = Indexer(settings=settings, embedder=embedder, store=store)
chat = ChatService(
settings=settings, store=store, embedder=embedder, provider=provider
@@ -127,6 +129,7 @@ def create_app(
indexer=indexer,
chat=chat,
last_sources=[],
embedder_warning=embedder_warning,
)
reindex_lock = asyncio.Lock()
@@ -172,18 +175,21 @@ def create_app(
@app.get("/api/status", response_model=StatusResponse)
async def status() -> StatusResponse:
"""Estado da indexação: PDFs lidos e vetores indexados."""
"""Estado da indexação: fontes lidas e vetores indexados."""
current = services.indexer.status()
errors = list(current.errors)
if services.embedder_warning:
errors.insert(0, services.embedder_warning)
return StatusResponse(
state=current.state,
pdfs_total=current.pdfs_total,
pdfs_indexed=current.pdfs_indexed,
sources_total=current.sources_total,
sources_indexed=current.sources_indexed,
chunks=current.chunks,
model=current.model,
last_indexed_at=current.last_indexed_at,
errors=current.errors,
pages_processed=current.pages_processed,
pages_total=current.pages_total,
errors=errors,
verses_indexed=current.verses_indexed,
verses_total=current.verses_total,
current_file=current.current_file,
)
@@ -191,7 +197,7 @@ def create_app(
async def reindex(
x_reindex_token: str | None = Header(default=None),
) -> ReindexResponse:
"""Força a releitura e reindexação dos PDFs."""
"""Força a releitura e reindexação do corpus."""
if settings.reindex_token:
if x_reindex_token != settings.reindex_token:
raise HTTPException(status_code=401, detail="Token inválido")
@@ -237,7 +243,6 @@ def create_app(
"book": source.book,
"chapter": source.chapter,
"verse": source.verse,
"page": source.page,
"file": source.file,
"score": round(source.score, 4),
}
@@ -270,7 +275,6 @@ def create_app(
book=source.book,
chapter=source.chapter,
verse=source.verse,
page=source.page,
file=source.file,
score=source.score,
)
@@ -287,7 +291,7 @@ def _sse(event: str, data: dict) -> str:
async def _run_initial_index(services: Services) -> None:
"""Indexa os PDFs na inicialização, sem bloquear o startup."""
"""Indexa o corpus na inicialização, sem bloquear o startup."""
await asyncio.to_thread(services.indexer.reindex)
@@ -312,28 +316,17 @@ def _build_default_app() -> FastAPI:
except Exception as exc: # pragma: no cover - depende do ambiente
logger.error("Falha ao inicializar a aplicação: %s", exc)
settings = get_settings()
embedder = _build_embedder(settings)
embedder, embedder_warning = _build_embedder(settings)
store = InMemoryVectorStore(embedder=embedder)
provider = build_provider(settings)
indexer = Indexer(
settings=settings,
extractor=PdfExtractor(
cache_dir=settings.ocr_cache_dir,
dpi=settings.ocr_dpi,
ocr_lang=settings.ocr_language,
ocr_timeout=settings.ocr_timeout,
min_legible_ratio=settings.min_legible_ratio,
),
parser=BibleParser(),
embedder=embedder,
store=store,
)
indexer = Indexer(settings=settings, embedder=embedder, store=store)
return create_app(
settings=settings,
store=store,
embedder=embedder,
provider=provider,
indexer=indexer,
embedder_warning=embedder_warning,
)