"""API HTTP do Pastor Online. Rotas: * `GET /api/health` — verificação de saúde * `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) """ 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 .chat import ChatService from .config import Settings, get_settings from .embedding import Embedder, HashEmbedder, SentenceTransformerEmbedder 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] embedder_warning: str | None = None def _build_embedder(settings: Settings) -> tuple[Embedder, str | None]: """Cria o embedder conforme o modelo configurado. 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(), None try: return ( SentenceTransformerEmbedder( settings.embedding_model, batch_size=settings.embedding_batch_size ), None, ) 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(), ( 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( settings: Settings | None = None, store: VectorStore | None = None, 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 provider is None: provider = build_provider(settings) if indexer is None: indexer = Indexer(settings=settings, 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=[], embedder_warning=embedder_warning, ) 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: 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, sources_total=current.sources_total, sources_indexed=current.sources_indexed, chunks=current.chunks, model=current.model, last_indexed_at=current.last_indexed_at, errors=errors, verses_indexed=current.verses_indexed, verses_total=current.verses_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 do corpus.""" 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, "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, 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 o corpus 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, embedder_warning = _build_embedder(settings) store = InMemoryVectorStore(embedder=embedder) provider = build_provider(settings) 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, ) app = _build_default_app()