feat(backend): add FastAPI routes with SSE chat and reindex guard
This commit is contained in:
1 parent
99bc46f053
commit
7a0396422f
3 files changed
+555
No files matched your search
@@ -60,6 +60,9 @@ class Settings(BaseSettings):
|
||||
# Limites de ingestão
|
||||
max_pdf_bytes: int = 200 * 1024 * 1024
|
||||
|
||||
# Indexação automática na inicialização
|
||||
auto_index: bool = True
|
||||
|
||||
@property
|
||||
def ocr_cache_dir(self) -> Path:
|
||||
return self.vector_db_dir / "ocr_cache"
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
@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()
|
||||
Reference in new issue
Block a user