MyPal / app /core /lifecycle.py
KhaledSalehKL1's picture
Deploy 9XAIPal: Gradio+FastAPI app, backend, React build
1086e43 verified
Raw
History Blame Contribute Delete
7.37 kB
"""FastAPI startup and shutdown lifecycle hooks."""
from contextlib import asynccontextmanager
from typing import AsyncIterator
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.core.logging import get_logger, setup_logging
from app.core.paths import ensure_storage_dirs
from app.database.connection import engine, verify_connection, async_session_factory
from app.database.migrations import apply_migrations
from app.database.pgvector import create_vector_index, ensure_vector_dimension
logger = get_logger(__name__)
async def _requeue_all_embeddings(session) -> None:
"""Dispatch embed_document Celery jobs for every document that has chunks.
Called after a vector-dimension migration wiped chunk_embeddings. Summaries
and figure descriptions are prompt-hash cached, so only the (cheap)
embedding pass re-runs. If Redis/Celery is down the jobs are simply not
queued β€” searches return empty until a re-embed happens, so warn loudly.
"""
from sqlalchemy import text
result = await session.execute(
text("SELECT DISTINCT document_id FROM chunks")
)
doc_ids = [row[0] for row in result.fetchall()]
if not doc_ids:
return
try:
from app.workers.tasks import embed_document
for doc_id in doc_ids:
embed_document.delay(str(doc_id))
logger.warning(
"Re-queued embedding jobs for %d document(s) after the vector-dimension "
"migration. Vector search returns sparse results until they finish.",
len(doc_ids),
)
except Exception:
logger.exception(
"Could not queue re-embedding jobs (is Redis running?). Vector search "
"will return nothing until documents are re-embedded."
)
async def _report_ai_backend() -> None:
"""Log which backend auto-detection picked β€” or, if nothing is usable,
the exact instructions the user asked for ("put your API key or your
Ollama connection"). Never fatal: the app still serves stored papers."""
from app.api.errors import ModelUnavailable, NoLLMConfigured
from app.llm.resolver import resolve_llm
try:
await resolve_llm() # logs "LLM backend: ..." on success
except NoLLMConfigured as e:
logger.error(str(e.model))
except ModelUnavailable as e:
logger.error("LLM backend misconfigured: %s", e.model)
async def _check_embedding_model_switch(session) -> None:
"""Detect stored embeddings made by a different model than the active one.
Vectors from different models are not comparable, so search quality
silently degrades after a backend switch. When EMBEDDING_PROVIDER is
pinned explicitly the switch is deliberate: wipe the stale vectors and
re-embed (summaries/figure descriptions are prompt-hash cached and don't
re-run). In auto mode only warn β€” a temporarily unreachable Ollama must
not trigger a destructive re-embed loop.
"""
from sqlalchemy import text
from app.api.errors import ModelUnavailable
from app.core.config import settings
from app.llm.resolver import resolve_embedding
try:
target = await resolve_embedding()
except ModelUnavailable as e:
logger.error("Embedding backend misconfigured: %s", e.model)
return
result = await session.execute(
text("SELECT DISTINCT embedding_model FROM chunk_embeddings")
)
stored = [row[0] for row in result.fetchall()]
stale = [m for m in stored if m != target.model]
if not stale:
return
pinned = (settings.embedding_provider or "auto").strip().lower() not in ("", "auto")
if not pinned:
logger.warning(
"Stored embeddings were generated by %s but the active embedding "
"model is %s (provider: %s) β€” these vectors are NOT comparable, so "
"search quality is degraded. If this switch is permanent, pin "
"EMBEDDING_PROVIDER in backend/.env and restart: the library will "
"be re-embedded automatically. Otherwise restore the old backend.",
", ".join(stale), target.model, target.provider,
)
return
logger.warning(
"EMBEDDING_PROVIDER is pinned to %s (model: %s) but stored embeddings "
"were generated by %s. Re-embedding the library with the new model.",
target.provider, target.model, ", ".join(stale),
)
await session.execute(
text("DELETE FROM chunk_embeddings WHERE embedding_model != :model"),
{"model": target.model},
)
await session.commit()
await _requeue_all_embeddings(session)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Application lifespan: startup and shutdown.
Long-running work (PDF ingestion, embedding) is dispatched to Celery workers.
"""
setup_logging()
logger.info("Starting 9XAIPal backend")
from app.core.config import settings
if settings.postgres_password == "9xaipal_dev_password":
logger.warning(
"PostgreSQL is using the default development password. "
"Set POSTGRES_PASSWORD in backend/.env before exposing this server to a network."
)
# Ensure storage directories exist
ensure_storage_dirs()
# Optional single-origin SPA serving for "your machine = server" mode.
# Done here (in lifespan) rather than at pure import time so the volume
# state is guaranteed stable when the decision is made (critical for
# Docker named volumes + uvicorn workers).
try:
_frontend_dist = Path("/app/frontend/dist")
_serve_frontend = os.getenv("SERVE_FRONTEND", "false").lower() in ("1", "true", "yes")
_has_dist = _frontend_dist.exists() and (_frontend_dist / "index.html").exists()
if _serve_frontend or _has_dist:
if _has_dist:
app.mount(
"/",
StaticFiles(directory=str(_frontend_dist), html=True, check_dir=False),
name="frontend-spa",
)
logger.info("SPA frontend mounted at / (single-port server mode active)")
except Exception as e:
logger.warning("Frontend SPA mount skipped (non-fatal): %s", e)
# Verify database connectivity and apply migrations
await verify_connection()
await apply_migrations()
# Report which AI backend auto-detection picked (Ollama β†’ cloud API keys
# β†’ clear configure-me message).
await _report_ai_backend()
# Sync the embedding column to the configured dimension, then ensure the
# search indexes exist (idempotent, cheap when nothing changed).
async with async_session_factory() as session:
dimension_migrated = await ensure_vector_dimension(session)
await create_vector_index(session)
await session.commit()
if dimension_migrated:
await _requeue_all_embeddings(session)
else:
# Dimension unchanged β€” but the embedding MODEL may have switched
# (e.g. moving the library from Ollama to a cloud embedder).
await _check_embedding_model_switch(session)
logger.info("9XAIPal backend ready")
yield
# Shutdown
logger.info("Shutting down 9XAIPal backend")
await engine.dispose()