import asyncio import hmac import logging import os from datetime import timedelta from dotenv import load_dotenv from quart import ( Quart, Response, jsonify, render_template, request, send_from_directory, ) from quart_jwt_extended import JWTManager, get_jwt_identity from tortoise import Tortoise import blueprints.artifacts import blueprints.integrations from blueprints.conversation.history import history_blueprint from blueprints.conversation.runs import ( drain_runs, expire_runs, runs_blueprint, ) from blueprints.rag.source_api import sources_blueprint import blueprints.conversation import blueprints.conversation.logic import blueprints.email import blueprints.rag import blueprints.users import blueprints.whatsapp import blueprints.imessage import blueprints.scheduled_messages import blueprints.cron import blueprints.beli import blueprints.users.models from blueprints.users.decorators import jwt_access_required from config.db import TORTOISE_CONFIG from metrics import render_metrics from blueprints.conversation.agents import build_agent from blueprints.ynab.sql_tool import ynab_sql from blueprints.ynab.sync import ynab_configured, ynab_sync_loop from utils.bookstack_mcp import load_bookstack_tools from utils.homebox_mcp import load_homebox_tools # Load environment variables load_dotenv() # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[logging.StreamHandler()], ) # Ensure YNAB and Mealie loggers are visible logging.getLogger("blueprints.ynab.sync").setLevel(logging.INFO) logging.getLogger("utils.mealie_service").setLevel(logging.INFO) logging.getLogger("blueprints.conversation.agents").setLevel(logging.INFO) app = Quart( __name__, static_folder="raggr-frontend/dist/static", template_folder="raggr-frontend/dist", ) app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "SECRET_KEY") app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30) app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB upload limit jwt = JWTManager(app) # Register blueprints app.register_blueprint(blueprints.users.user_blueprint) app.register_blueprint(blueprints.conversation.conversation_blueprint) app.register_blueprint(blueprints.email.email_blueprint) app.register_blueprint(blueprints.rag.rag_blueprint) app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint) app.register_blueprint(blueprints.imessage.imessage_blueprint) app.register_blueprint(blueprints.scheduled_messages.scheduled_messages_blueprint) app.register_blueprint(blueprints.cron.cron_blueprint) app.register_blueprint(blueprints.beli.beli_blueprint) app.register_blueprint(blueprints.artifacts.artifacts_blueprint) app.register_blueprint(blueprints.integrations.integrations_blueprint) app.register_blueprint(history_blueprint) app.register_blueprint(runs_blueprint) app.register_blueprint(sources_blueprint) async def _obsidian_sync_loop(): """Background task that incrementally syncs Obsidian documents to pgvector.""" from blueprints.rag.logic import sync_obsidian_documents interval = int(os.getenv("OBSIDIAN_SYNC_INTERVAL", "60")) logger = logging.getLogger("obsidian_sync") logger.info(f"Obsidian sync watcher started (interval={interval}s)") while True: try: result = await sync_obsidian_documents() if result["added"] or result["updated"] or result["deleted"]: logger.info( f"Obsidian sync: {result['added']} added, " f"{result['updated']} updated, {result['deleted']} deleted" ) except Exception: logger.exception("Obsidian sync error") await asyncio.sleep(interval) # Initialize Tortoise ORM with lifecycle hooks @app.while_serving async def lifespan(): logging.info("Initializing Tortoise ORM...") await Tortoise.init(config=TORTOISE_CONFIG) logging.info("Tortoise ORM initialized successfully") await expire_runs() import aiosqlite from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver checkpoint_path = os.getenv("CHECKPOINT_DB_PATH", "/app/checkpoints/checkpoints.db") async with aiosqlite.connect(checkpoint_path) as conn: checkpointer = AsyncSqliteSaver(conn) await checkpointer.setup() app.main_agent = build_agent(checkpointer) from blueprints.conversation.summary import SummaryStore app.conversation_summary_store = SummaryStore( os.getenv("CONVERSATION_SUMMARY_DB_PATH", checkpoint_path + ".summaries.db") ) admin_tools = await load_homebox_tools() + await load_bookstack_tools() if ynab_configured(): admin_tools.append(ynab_sql) app.admin_agent = ( build_agent(checkpointer, admin_tools) if admin_tools else app.main_agent ) logging.info(f"Agent checkpointer ready at {checkpoint_path}") from utils.cronicle import ensure_beli_nightly_event await ensure_beli_nightly_event() background_tasks = [] if os.getenv("OBSIDIAN_CONTINUOUS_SYNC") == "true": background_tasks.append(asyncio.create_task(_obsidian_sync_loop())) if ynab_configured(): background_tasks.append(asyncio.create_task(ynab_sync_loop())) yield # Let in-flight chat runs finish while the agent and checkpointer are alive. await drain_runs(app) for task in background_tasks: task.cancel() logging.info("Closing Tortoise ORM connections...") await Tortoise.close_connections() # Serve React static files @app.route("/static/") async def static_files(filename): return await send_from_directory(app.static_folder, filename) # Allowed file extensions for static frontend assets ALLOWED_STATIC_EXTENSIONS = { ".html", ".css", ".js", ".svg", ".png", ".ico", ".jpg", ".jpeg", ".webp", ".woff", ".woff2", ".ttf", ".txt", } # JSON files explicitly allowed to be served (e.g. PWA manifest) ALLOWED_JSON_FILES = {"manifest.json"} # Prometheus metrics endpoint. Fail-closed: 403 unless a valid bearer token # matching METRICS_TOKEN is presented. Registered before the catch-all SPA # route so it is never shadowed. @app.get("/metrics") async def metrics_endpoint(): expected = os.environ.get("METRICS_TOKEN", "") if not expected: return "unauthorized", 403 auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): return "unauthorized", 403 provided = auth[len("Bearer ") :].strip() if not hmac.compare_digest(provided.encode(), expected.encode()): return "unauthorized", 403 return Response( render_metrics(), status=200, content_type="text/plain; version=0.0.4", ) # Serve the React app for all routes (catch-all) @app.route("/", defaults={"path": ""}) @app.route("/") async def serve_react_app(path): if path: ext = os.path.splitext(path)[1].lower() basename = os.path.basename(path) allowed = ext in ALLOWED_STATIC_EXTENSIONS or ( ext == ".json" and basename in ALLOWED_JSON_FILES ) if allowed and os.path.exists(os.path.join(app.template_folder, path)): return await send_from_directory(app.template_folder, path) return await render_template("index.html") # Health check used by the Docker healthcheck and Caddy's active health checks @app.route("/api/health", methods=["GET"]) async def health(): try: await Tortoise.get_connection("default").execute_query("SELECT 1") except Exception: return jsonify({"status": "unhealthy"}), 503 return jsonify({"status": "ok"}) @app.route("/api/messages", methods=["GET"]) @jwt_access_required async def get_messages(): current_user_uuid = get_jwt_identity() user = await blueprints.users.models.User.get(id=current_user_uuid) conversation = await blueprints.conversation.logic.get_conversation_for_user( user=user ) # Prefetch related messages await conversation.fetch_related("messages") # Manually serialize the conversation with messages messages = [] for msg in conversation.messages: messages.append( { "id": str(msg.id), "text": msg.text, "speaker": msg.speaker.value, "created_at": msg.created_at.isoformat(), } ) return jsonify( { "id": str(conversation.id), "name": conversation.name, "messages": messages, "created_at": conversation.created_at.isoformat(), "updated_at": conversation.updated_at.isoformat(), } ) if __name__ == "__main__": from hypercorn.asyncio import serve from hypercorn.config import Config config = Config() config.bind = ["0.0.0.0:8080"] config.accesslog = "-" config.errorlog = "-" # SSE subscribers reconnect to the new deployment, so connections close # quickly; lifespan shutdown must outlast chat-run draining (drain_runs). config.graceful_timeout = 10 config.shutdown_timeout = float(os.getenv("CHAT_RUN_DRAIN_SECONDS", "1800")) + 30 asyncio.run(serve(app, config))