Files
simbarag/app.py
T
ryan 0cf6229441 Add Artifact model and owner-scoped artifacts API
GET /api/artifacts?conversation_id= lists ArtifactRefs without data;
GET /api/artifacts/<id> returns the full artifact. Both 404 for other
users. create_artifact_from_output persists ToolArtifact dicts and drops
ones over 256 KB serialised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:53:22 -04:00

247 lines
7.7 KiB
Python

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.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 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("utils.ynab_service").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)
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")
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)
admin_tools = await load_homebox_tools() + await load_bookstack_tools()
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()
watcher_task = None
if os.getenv("OBSIDIAN_CONTINUOUS_SYNC") == "true":
watcher_task = asyncio.create_task(_obsidian_sync_loop())
yield
if watcher_task is not None:
watcher_task.cancel()
logging.info("Closing Tortoise ORM connections...")
await Tortoise.close_connections()
# Serve React static files
@app.route("/static/<path:filename>")
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("/<path:path>")
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__":
app.run(host="0.0.0.0", port=8080, debug=True)