5809ad3adb
Shared runtime - One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions, memories, summaries and metrics match across channels. - Serialize turns per checkpoint thread; /query validates input and returns 409 while a durable run owns the conversation. Reconnectable chat (durable runs) - POST /runs creates an idempotent, owner-scoped run; GET .../events replays an ordered SSE log by cursor, so generation survives refresh and device switches. Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter. - Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of polling; keep buffered text when a run is stopped. - Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded. Scheduled messages and cron - Atomic delivery claims, attempt history and explicit retries; ambiguous sends become "uncertain" and are never resent automatically. - Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner. - Cancelling never rewrites a started delivery; series stop via tombstones. Legacy sent rows are not forked into new series. Malformed bodies return 400. History, integrations and retrieval - Message-content search, pinned conversations, JSON/Markdown export and a memory editor. - Integration status panel and YNAB data-freshness provenance. - Date-filtered document/note search with stable citations and a source viewer. Migration 20 is generated by aerich. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
285 lines
9.3 KiB
Python
285 lines
9.3 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.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/<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__":
|
|
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))
|