341 lines
11 KiB
Python
341 lines
11 KiB
Python
"""API HTTP do Pastor Online.
|
|
|
|
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
|
|
* `POST /api/chat` — chat em streaming (SSE)
|
|
* `GET /api/sources` — últimos trechos recuperados (inspeção)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass
|
|
from typing import AsyncIterator
|
|
|
|
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 (
|
|
ChatRequest,
|
|
HealthResponse,
|
|
ReindexResponse,
|
|
Source,
|
|
SourcesResponse,
|
|
StatusResponse,
|
|
)
|
|
from .vector_store import InMemoryVectorStore, VectorStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class Services:
|
|
"""Dependências de longa duração da aplicação."""
|
|
|
|
settings: Settings
|
|
store: VectorStore
|
|
embedder: Embedder
|
|
provider: LLMProvider
|
|
indexer: Indexer
|
|
chat: ChatService
|
|
last_sources: list[Source]
|
|
|
|
|
|
def _build_embedder(settings: Settings) -> Embedder:
|
|
"""Cria o embedder conforme o modelo configurado.
|
|
|
|
`hash` e modelos desconhecidos usam o embedder determinístico; os demais
|
|
usam sentence-transformers.
|
|
"""
|
|
model = settings.embedding_model.strip().lower()
|
|
if model in ("", "hash", "hash-test", "none"):
|
|
return HashEmbedder()
|
|
try:
|
|
return SentenceTransformerEmbedder(
|
|
settings.embedding_model, batch_size=settings.embedding_batch_size
|
|
)
|
|
except Exception as exc: # pragma: no cover - falha de download do modelo
|
|
logger.error(
|
|
"Não foi possível carregar o modelo de embedding %s: %s. "
|
|
"Usando embedder determinístico.",
|
|
settings.embedding_model,
|
|
exc,
|
|
)
|
|
return HashEmbedder()
|
|
|
|
|
|
def create_app(
|
|
settings: Settings | None = None,
|
|
store: VectorStore | None = None,
|
|
embedder: Embedder | None = None,
|
|
provider: LLMProvider | None = None,
|
|
indexer: Indexer | None = None,
|
|
) -> FastAPI:
|
|
"""Cria a aplicação FastAPI. As dependências podem ser injetadas nos testes."""
|
|
settings = settings or get_settings()
|
|
|
|
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,
|
|
)
|
|
|
|
chat = ChatService(
|
|
settings=settings, store=store, embedder=embedder, provider=provider
|
|
)
|
|
|
|
services = Services(
|
|
settings=settings,
|
|
store=store,
|
|
embedder=embedder,
|
|
provider=provider,
|
|
indexer=indexer,
|
|
chat=chat,
|
|
last_sources=[],
|
|
)
|
|
reindex_lock = asyncio.Lock()
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
"""Dispara a indexação inicial sem bloquear o startup."""
|
|
if settings.auto_index:
|
|
asyncio.create_task(_run_initial_index(services))
|
|
yield
|
|
close = getattr(provider, "aclose", None)
|
|
if close is not None:
|
|
await close()
|
|
|
|
app = FastAPI(
|
|
title="Pastor Online",
|
|
description="Sistema RAG para consulta bíblica com agente pastoral.",
|
|
version="1.0.0",
|
|
lifespan=lifespan,
|
|
)
|
|
app.state.services = services
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.cors_origin_list,
|
|
allow_credentials=False,
|
|
allow_methods=["GET", "POST"],
|
|
allow_headers=["Content-Type", "X-Reindex-Token"],
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Rotas
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.get("/api/health", response_model=HealthResponse)
|
|
async def health() -> HealthResponse:
|
|
"""Verificação de saúde do serviço."""
|
|
state = services.indexer.status().state
|
|
return HealthResponse(
|
|
status="ok",
|
|
llm_provider=settings.llm_provider,
|
|
indexing="indexing" if state == "indexing" else "ready",
|
|
)
|
|
|
|
@app.get("/api/status", response_model=StatusResponse)
|
|
async def status() -> StatusResponse:
|
|
"""Estado da indexação: PDFs lidos e vetores indexados."""
|
|
current = services.indexer.status()
|
|
return StatusResponse(
|
|
state=current.state,
|
|
pdfs_total=current.pdfs_total,
|
|
pdfs_indexed=current.pdfs_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,
|
|
current_file=current.current_file,
|
|
)
|
|
|
|
@app.post("/api/reindex", response_model=ReindexResponse, status_code=202)
|
|
async def reindex(
|
|
x_reindex_token: str | None = Header(default=None),
|
|
) -> ReindexResponse:
|
|
"""Força a releitura e reindexação dos PDFs."""
|
|
if settings.reindex_token:
|
|
if x_reindex_token != settings.reindex_token:
|
|
raise HTTPException(status_code=401, detail="Token inválido")
|
|
|
|
if reindex_lock.locked():
|
|
raise HTTPException(
|
|
status_code=409, detail="Uma reindexação já está em andamento"
|
|
)
|
|
|
|
# O lock é adquirido e liberado pela tarefa de background: a rota apenas
|
|
# agenda o trabalho. Liberá-lo aqui permitiria reindexações concorrentes.
|
|
await reindex_lock.acquire()
|
|
asyncio.create_task(_run_reindex(services, reindex_lock))
|
|
|
|
return ReindexResponse(state="indexing")
|
|
|
|
@app.post("/api/chat")
|
|
async def chat(request: ChatRequest) -> StreamingResponse:
|
|
"""Responde a uma pergunta em streaming (SSE)."""
|
|
if len(request.message) > settings.max_message_length:
|
|
raise HTTPException(status_code=422, detail="Mensagem muito longa")
|
|
|
|
history = [
|
|
{"role": entry.role, "content": entry.content} for entry in request.history
|
|
]
|
|
|
|
async def event_stream() -> AsyncIterator[str]:
|
|
try:
|
|
result = await services.chat.answer(request.message, history)
|
|
except LLMProviderError as exc:
|
|
logger.error("Falha no provedor de LLM: %s", exc)
|
|
yield _sse("error", {"message": "Provedor de LLM indisponível."})
|
|
return
|
|
except Exception as exc: # pragma: no cover - erro inesperado
|
|
logger.exception("Erro ao processar a pergunta")
|
|
yield _sse("error", {"message": "Erro interno ao processar a pergunta."})
|
|
return
|
|
|
|
sources = [
|
|
{
|
|
"reference": source.reference,
|
|
"text": source.text,
|
|
"book": source.book,
|
|
"chapter": source.chapter,
|
|
"verse": source.verse,
|
|
"page": source.page,
|
|
"file": source.file,
|
|
"score": round(source.score, 4),
|
|
}
|
|
for source in result.sources
|
|
]
|
|
services.last_sources = result.sources
|
|
|
|
yield _sse("sources", {"sources": sources})
|
|
yield _sse("token", {"text": result.text})
|
|
yield _sse("done", {"citations": result.citations})
|
|
|
|
return StreamingResponse(
|
|
event_stream(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|
|
|
|
@app.get("/api/sources", response_model=SourcesResponse)
|
|
async def sources() -> SourcesResponse:
|
|
"""Trechos consultados na última resposta."""
|
|
return SourcesResponse(
|
|
sources=[
|
|
Source(
|
|
reference=source.reference,
|
|
text=source.text,
|
|
book=source.book,
|
|
chapter=source.chapter,
|
|
verse=source.verse,
|
|
page=source.page,
|
|
file=source.file,
|
|
score=source.score,
|
|
)
|
|
for source in services.last_sources
|
|
]
|
|
)
|
|
|
|
return app
|
|
|
|
|
|
def _sse(event: str, data: dict) -> str:
|
|
"""Serializa um evento SSE."""
|
|
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
|
|
|
|
async def _run_initial_index(services: Services) -> None:
|
|
"""Indexa os PDFs na inicialização, sem bloquear o startup."""
|
|
await asyncio.to_thread(services.indexer.reindex)
|
|
|
|
|
|
async def _run_reindex(services: Services, lock: asyncio.Lock) -> None:
|
|
"""Executa a reindexação em background, segurando o lock durante o trabalho."""
|
|
try:
|
|
await asyncio.to_thread(services.indexer.reindex, True)
|
|
finally:
|
|
if lock.locked():
|
|
lock.release()
|
|
|
|
|
|
def _build_default_app() -> FastAPI:
|
|
"""Cria a aplicação padrão, tolerando falha no carregamento do Chroma.
|
|
|
|
O Uvicorn importa este módulo no arranque. Se o banco vetorial não puder ser
|
|
aberto (volume ainda não montado, por exemplo), a aplicação precisa subir e
|
|
reportar o erro em `/api/status`, não recusar o arranque.
|
|
"""
|
|
try:
|
|
return create_app()
|
|
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)
|
|
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,
|
|
)
|
|
return create_app(
|
|
settings=settings,
|
|
store=store,
|
|
embedder=embedder,
|
|
provider=provider,
|
|
indexer=indexer,
|
|
)
|
|
|
|
|
|
app = _build_default_app()
|