Spaces:
Sleeping
Sleeping
| """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) | |
| 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() | |