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
+99
-169
@@ -1,12 +1,14 @@
|
||||
"""Indexador: orquestra extração, parsing, embeddings e persistência vetorial.
|
||||
"""Indexador: orquestra leitura do corpus, embeddings e persistência vetorial.
|
||||
|
||||
O manifesto (`manifest.json`) registra, por arquivo, o SHA-256 do conteúdo, o
|
||||
estado (`complete`, `partial`, `error`) e as páginas com falha. Arquivos cujo
|
||||
hash não mudou não são reprocessados.
|
||||
estado (`complete` ou `error`) e a contagem de chunks. Arquivos cujo hash não
|
||||
mudou não são reprocessados, então reiniciar o container não repete o trabalho de
|
||||
embedding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
@@ -14,32 +16,41 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .bible_parser import BibleParser, parse_book_index
|
||||
from .config import Settings
|
||||
from .corpus import CorpusParseError, VerseChunk, parse_usfx
|
||||
from .embedding import Embedder
|
||||
from .extraction import PageText, file_sha256
|
||||
from .vector_store import VectorStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_COMPLETE = "complete"
|
||||
STATE_PARTIAL = "partial"
|
||||
STATE_ERROR = "error"
|
||||
|
||||
CORPUS_SUFFIXES = (".xml",)
|
||||
|
||||
|
||||
def file_sha256(path: Path, chunk_size: int = 1024 * 1024) -> str:
|
||||
"""SHA-256 do conteúdo do arquivo, lido em blocos."""
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as handle:
|
||||
while chunk := handle.read(chunk_size):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexStatus:
|
||||
"""Estado público da indexação."""
|
||||
|
||||
state: str = "ready"
|
||||
pdfs_total: int = 0
|
||||
pdfs_indexed: int = 0
|
||||
sources_total: int = 0
|
||||
sources_indexed: int = 0
|
||||
chunks: int = 0
|
||||
model: str = ""
|
||||
last_indexed_at: str | None = None
|
||||
errors: list[str] = field(default_factory=list)
|
||||
pages_processed: int = 0
|
||||
pages_total: int = 0
|
||||
verses_indexed: int = 0
|
||||
verses_total: int = 0
|
||||
current_file: str | None = None
|
||||
|
||||
|
||||
@@ -49,7 +60,6 @@ class _ManifestEntry:
|
||||
state: str
|
||||
chunks: int = 0
|
||||
indexed_at: str = ""
|
||||
failed_pages: list[int] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -57,24 +67,19 @@ class _ManifestEntry:
|
||||
"state": self.state,
|
||||
"chunks": self.chunks,
|
||||
"indexed_at": self.indexed_at,
|
||||
"failed_pages": self.failed_pages,
|
||||
}
|
||||
|
||||
|
||||
class Indexer:
|
||||
"""Coordena a indexação dos PDFs presentes em `settings.pdf_dir`."""
|
||||
"""Coordena a indexação dos corpora presentes em `settings.corpus_dir`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
extractor,
|
||||
parser: BibleParser,
|
||||
embedder: Embedder,
|
||||
store: VectorStore,
|
||||
):
|
||||
self.settings = settings
|
||||
self.extractor = extractor
|
||||
self.parser = parser
|
||||
self.embedder = embedder
|
||||
self.store = store
|
||||
self._manifest: dict[str, _ManifestEntry] = {}
|
||||
@@ -82,11 +87,16 @@ class Indexer:
|
||||
self._last_indexed_at: str | None = None
|
||||
self._errors: list[str] = []
|
||||
self._indexing = False
|
||||
self._pages_processed = 0
|
||||
self._pages_total = 0
|
||||
self._verses_indexed = 0
|
||||
self._verses_total = 0
|
||||
self._current_file: str | None = None
|
||||
self._load_manifest()
|
||||
|
||||
@property
|
||||
def reindex_lock(self) -> threading.Lock:
|
||||
"""Lock que serializa reindexações concorrentes."""
|
||||
return self._lock
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Manifesto
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -106,7 +116,6 @@ class Indexer:
|
||||
state=entry.get("state", STATE_ERROR),
|
||||
chunks=int(entry.get("chunks", 0)),
|
||||
indexed_at=entry.get("indexed_at", ""),
|
||||
failed_pages=list(entry.get("failed_pages", [])),
|
||||
)
|
||||
|
||||
def _save_manifest(self) -> None:
|
||||
@@ -120,86 +129,55 @@ class Indexer:
|
||||
temporary.replace(path)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Índice de livros
|
||||
# Progresso
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _book_starts(self, pages: list[PageText]) -> dict[str, int]:
|
||||
"""Índice `{livro: página}` do PDF, memoizado em disco.
|
||||
def mark_indexing(self) -> None:
|
||||
"""Sinaliza que uma indexação está em andamento, zerando contadores."""
|
||||
self._indexing = True
|
||||
self._verses_indexed = 0
|
||||
self._verses_total = 0
|
||||
self._current_file = None
|
||||
|
||||
As primeiras páginas do documento contêm o índice de livros. Se ele não
|
||||
puder ser lido, devolve um dicionário vazio: o parser cairá para o
|
||||
cabeçalho de cada página.
|
||||
"""
|
||||
cached_path = self.settings.book_index_path
|
||||
if cached_path.exists():
|
||||
try:
|
||||
cached = json.loads(cached_path.read_text(encoding="utf-8"))
|
||||
if cached:
|
||||
return {str(key): int(value) for key, value in cached.items()}
|
||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("Índice de livros ilegível em %s: %s", cached_path, exc)
|
||||
|
||||
starts: dict[str, int] = {}
|
||||
for page in pages[:12]:
|
||||
if not page.text.strip():
|
||||
continue
|
||||
starts.update(parse_book_index(page.text))
|
||||
if len(starts) >= 20:
|
||||
break
|
||||
|
||||
if starts:
|
||||
try:
|
||||
cached_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cached_path.write_text(
|
||||
json.dumps(starts, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
except OSError as exc: # pragma: no cover
|
||||
logger.warning("Não foi possível gravar o índice de livros: %s", exc)
|
||||
return starts
|
||||
def report_progress(self, file_name: str, verses_done: int, verses_total: int) -> None:
|
||||
"""Registra o progresso da indexação de um arquivo."""
|
||||
self._current_file = file_name
|
||||
self._verses_indexed = verses_done
|
||||
self._verses_total = verses_total
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Indexação
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def mark_indexing(self) -> None:
|
||||
"""Sinaliza que uma indexação está em andamento."""
|
||||
self._indexing = True
|
||||
self._pages_processed = 0
|
||||
self._pages_total = 0
|
||||
self._current_file = None
|
||||
|
||||
def report_progress(
|
||||
self, pdf_name: str, pages_done: int, pages_total: int
|
||||
) -> None:
|
||||
"""Registra o progresso da extração de um arquivo."""
|
||||
self._current_file = pdf_name
|
||||
self._pages_processed = pages_done
|
||||
self._pages_total = pages_total
|
||||
|
||||
def discover_pdfs(self) -> list[Path]:
|
||||
"""Lista os PDFs do diretório configurado, ordenados por nome."""
|
||||
directory = self.settings.pdf_dir
|
||||
def discover_sources(self) -> list[Path]:
|
||||
"""Lista os arquivos de corpus do diretório configurado, por nome."""
|
||||
directory = self.settings.corpus_dir
|
||||
if not directory.exists():
|
||||
return []
|
||||
return sorted(path for path in directory.glob("*.pdf") if path.is_file())
|
||||
return sorted(
|
||||
path
|
||||
for suffix in CORPUS_SUFFIXES
|
||||
for path in directory.glob(f"*{suffix}")
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
def reindex(self, force: bool = False) -> IndexStatus:
|
||||
"""Indexa os PDFs novos ou alterados."""
|
||||
"""Indexa os corpora novos ou alterados."""
|
||||
with self._lock:
|
||||
self._errors = []
|
||||
self.mark_indexing()
|
||||
pdfs = self.discover_pdfs()
|
||||
sources = self.discover_sources()
|
||||
|
||||
try:
|
||||
for pdf in pdfs:
|
||||
for source in sources:
|
||||
try:
|
||||
self._index_file(pdf, force=force)
|
||||
self._index_file(source, force=force)
|
||||
except Exception as exc: # pragma: no cover - falha inesperada
|
||||
logger.exception("Falha ao indexar %s", pdf.name)
|
||||
self._manifest[pdf.name] = _ManifestEntry(
|
||||
logger.exception("Falha ao indexar %s", source.name)
|
||||
self._manifest[source.name] = _ManifestEntry(
|
||||
sha256="", state=STATE_ERROR, indexed_at=_now()
|
||||
)
|
||||
self._errors.append(f"{pdf.name}: {exc}")
|
||||
self._errors.append(f"{source.name}: {exc}")
|
||||
|
||||
self._save_manifest()
|
||||
self._last_indexed_at = _now()
|
||||
@@ -208,98 +186,59 @@ class Indexer:
|
||||
|
||||
return self.status()
|
||||
|
||||
def _extract_pages(self, pdf: Path, file_hash: str) -> list:
|
||||
"""Extrai as páginas do PDF, usando OCR paralelo quando possível."""
|
||||
extract_parallel = getattr(self.extractor, "extract_pages_parallel", None)
|
||||
if extract_parallel is None:
|
||||
return list(self.extractor.extract_document(pdf, file_hash=file_hash))
|
||||
def _index_file(self, source: Path, force: bool) -> None:
|
||||
file_hash = file_sha256(source)
|
||||
entry = self._manifest.get(source.name)
|
||||
|
||||
page_count = _pdf_page_count(pdf)
|
||||
pages: list = []
|
||||
batch_size = max(self.settings.ocr_workers, 1) * 4
|
||||
|
||||
for batch in self._page_batches(pdf, batch_size):
|
||||
raw_pages = [
|
||||
(page_number, text)
|
||||
for page_number, text in batch
|
||||
]
|
||||
extracted = extract_parallel(
|
||||
raw_pages,
|
||||
file_hash=file_hash,
|
||||
path=pdf,
|
||||
workers=self.settings.ocr_workers,
|
||||
)
|
||||
pages.extend(extracted)
|
||||
self.report_progress(pdf.name, len(pages), page_count)
|
||||
|
||||
return sorted(pages, key=lambda page: page.page)
|
||||
|
||||
def _page_batches(self, pdf: Path, batch_size: int):
|
||||
"""Gera lotes de (número_da_página, texto_da_camada)."""
|
||||
import pymupdf
|
||||
|
||||
with pymupdf.open(pdf) as document:
|
||||
batch: list[tuple[int, str]] = []
|
||||
for index in range(len(document)):
|
||||
page = document[index]
|
||||
try:
|
||||
text = page.get_text("text") or ""
|
||||
except Exception: # pragma: no cover - PDF malformado
|
||||
text = ""
|
||||
batch.append((index + 1, text))
|
||||
if len(batch) >= batch_size:
|
||||
yield batch
|
||||
batch = []
|
||||
if batch:
|
||||
yield batch
|
||||
|
||||
def _index_file(self, pdf: Path, force: bool) -> None:
|
||||
file_hash = file_sha256(pdf)
|
||||
entry = self._manifest.get(pdf.name)
|
||||
|
||||
if entry is not None and entry.sha256 == file_hash and not force:
|
||||
logger.info("%s já está indexado; pulando", pdf.name)
|
||||
if entry is not None and entry.sha256 == file_hash and entry.state == STATE_COMPLETE and not force:
|
||||
logger.info("%s já está indexado; pulando", source.name)
|
||||
return
|
||||
|
||||
logger.info("Indexando %s", pdf.name)
|
||||
pages = self._extract_pages(pdf, file_hash)
|
||||
failed_pages = [page.page for page in pages if page.failed]
|
||||
logger.info("Indexando %s", source.name)
|
||||
self.report_progress(source.name, 0, 0)
|
||||
|
||||
book_starts = self._book_starts(pages)
|
||||
chunks = self.parser.parse(pages, pdf.name, book_starts)
|
||||
try:
|
||||
chunks = list(parse_usfx(source, file_name=source.name))
|
||||
except CorpusParseError as exc:
|
||||
self._manifest[source.name] = _ManifestEntry(
|
||||
sha256=file_hash, state=STATE_ERROR, indexed_at=_now()
|
||||
)
|
||||
self._errors.append(str(exc))
|
||||
return
|
||||
|
||||
if not chunks:
|
||||
state = STATE_ERROR
|
||||
self._errors.append(f"{pdf.name}: nenhum versículo pôde ser extraído")
|
||||
elif failed_pages:
|
||||
state = STATE_PARTIAL
|
||||
self._errors.append(
|
||||
f"{pdf.name}: {len(failed_pages)} página(s) falharam na extração"
|
||||
self._manifest[source.name] = _ManifestEntry(
|
||||
sha256=file_hash, state=STATE_ERROR, indexed_at=_now()
|
||||
)
|
||||
else:
|
||||
state = STATE_COMPLETE
|
||||
self._errors.append(f"{source.name}: nenhum versículo pôde ser lido")
|
||||
return
|
||||
|
||||
# Remove os chunks anteriores deste arquivo antes de gravar os novos.
|
||||
self.store.delete_file(pdf.name)
|
||||
self.store.delete_file(source.name)
|
||||
|
||||
if chunks:
|
||||
vectors = self.embedder.encode([chunk.text for chunk in chunks])
|
||||
self.store.upsert(chunks, vectors)
|
||||
total = len(chunks)
|
||||
# O total é publicado antes do primeiro lote: a barra de progresso do
|
||||
# frontend precisa do denominador assim que a leitura termina, e não só
|
||||
# depois do primeiro embedding (que é a etapa mais lenta).
|
||||
self.report_progress(source.name, 0, total)
|
||||
batch_size = max(1, self.settings.index_batch_size)
|
||||
for start in range(0, total, batch_size):
|
||||
batch = chunks[start : start + batch_size]
|
||||
vectors = self.embedder.encode([chunk.text for chunk in batch])
|
||||
self.store.upsert(batch, vectors)
|
||||
self.report_progress(source.name, min(start + batch_size, total), total)
|
||||
|
||||
self._manifest[pdf.name] = _ManifestEntry(
|
||||
self._manifest[source.name] = _ManifestEntry(
|
||||
sha256=file_hash,
|
||||
state=state,
|
||||
chunks=len(chunks),
|
||||
state=STATE_COMPLETE,
|
||||
chunks=total,
|
||||
indexed_at=_now(),
|
||||
failed_pages=failed_pages,
|
||||
)
|
||||
|
||||
def status(self) -> IndexStatus:
|
||||
"""Estado atual da indexação."""
|
||||
indexed = sum(
|
||||
1
|
||||
for entry in self._manifest.values()
|
||||
if entry.state in (STATE_COMPLETE, STATE_PARTIAL)
|
||||
1 for entry in self._manifest.values() if entry.state == STATE_COMPLETE
|
||||
)
|
||||
if self._indexing:
|
||||
state = "indexing"
|
||||
@@ -309,28 +248,19 @@ class Indexer:
|
||||
state = "ready"
|
||||
return IndexStatus(
|
||||
state=state,
|
||||
pdfs_total=len(self.discover_pdfs()),
|
||||
pdfs_indexed=indexed,
|
||||
sources_total=len(self.discover_sources()),
|
||||
sources_indexed=indexed,
|
||||
chunks=self.store.count(),
|
||||
model=self.settings.embedding_model,
|
||||
# Reporta o embedder *em uso*, não o configurado: se o modelo falhou
|
||||
# ao carregar e houve fallback, o operador precisa ver isso aqui.
|
||||
model=getattr(self.embedder, "name", self.settings.embedding_model),
|
||||
last_indexed_at=self._last_indexed_at,
|
||||
errors=list(self._errors),
|
||||
pages_processed=self._pages_processed,
|
||||
pages_total=self._pages_total,
|
||||
verses_indexed=self._verses_indexed,
|
||||
verses_total=self._verses_total,
|
||||
current_file=self._current_file,
|
||||
)
|
||||
|
||||
|
||||
def _pdf_page_count(pdf: Path) -> int:
|
||||
"""Número de páginas do PDF, ou 0 se não for possível determinar."""
|
||||
try:
|
||||
import pymupdf
|
||||
|
||||
with pymupdf.open(pdf) as document:
|
||||
return len(document)
|
||||
except Exception: # pragma: no cover - PDF malformado
|
||||
return 0
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
Reference in new issue
Block a user