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
+45
-52
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user