Files
FabledScribe/src/fabledassistant/app.py
T
bvandeusen 49325816a3 fix(journal): chat-only system prompt; don't pre-warm OLLAMA_MODEL
Two architectural bugs in the conversation+curator rollout that
explain the no-response chat in dev:

1. Journal system prompt still instructed tool calls.
   JOURNAL_CALIBRATION instructed the model to CALL record_moment,
   search_notes, save_person, etc. — but the chat surface ships tools=[]
   per the new architecture. The model received contradictory orders
   ('use these tools' + 'you have no tools') and produced either empty
   output or tool-call-shaped text that gets stripped to empty content,
   surfacing as status=error or stuck status=generating messages.
   Replaced with a chat-only calibration: ~25 lines focused on tone,
   length, anti-coaching, and the load-bearing rule 'never claim to
   have done anything for the user' (the curator handles capture
   silently and separately). JOURNAL_PERSONA also rewritten to drop
   the 'use tools to act on their behalf' line.

2. Pre-warm warmed Config.OLLAMA_MODEL ahead of user's real choice.
   _pull_model(Config.OLLAMA_MODEL, warm=True) at boot pushed the
   system default (qwen3:latest) into VRAM before _warm_user_models()
   ran for each user's actual default_model setting. On a single-GPU
   setup the second warm could swap the first out — so the user's
   chat model wasn't necessarily resident when their first message
   landed. Now we just pull the supporting models without warming
   them; only user-configured chat models get warm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:42:33 -04:00

437 lines
19 KiB
Python

import logging
import time
import traceback as tb_module
from pathlib import Path
import httpx
from quart import Quart, g, jsonify, make_response, request, send_from_directory
from fabledassistant.config import Config
from fabledassistant.routes.admin import admin_bp
from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.journal import journal_bp
from fabledassistant.routes.export import export_bp
from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.images import images_bp
from fabledassistant.routes.milestones import milestones_bp
from fabledassistant.routes.task_logs import task_logs_bp
from fabledassistant.routes.projects import projects_bp
from fabledassistant.routes.push import push_bp
from fabledassistant.routes.fable_mcp_dist import fable_mcp_dist_bp
from fabledassistant.routes.quick_capture import quick_capture_bp
from fabledassistant.routes.settings import settings_bp
from fabledassistant.routes.tasks import tasks_bp
from fabledassistant.routes.groups import groups_bp
from fabledassistant.routes.shares import shares_bp
from fabledassistant.routes.in_app_notifications import notifications_bp
from fabledassistant.routes.users import users_bp
from fabledassistant.routes.api_keys import api_keys_bp
from fabledassistant.routes.events import events_bp
from fabledassistant.routes.search import search_bp
from fabledassistant.routes.voice import voice_bp
from fabledassistant.routes.profile import profile_bp
from fabledassistant.routes.knowledge import knowledge_bp
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
def create_app() -> Quart:
# Configure logging
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=getattr(logging, Config.LOG_LEVEL.upper(), logging.INFO),
)
# Validate config early so misconfigurations surface immediately with clear messages
try:
Config.validate()
except ValueError as exc:
logging.getLogger(__name__).error("Invalid configuration:\n%s", exc)
raise
app = Quart(__name__, static_folder=None)
app.secret_key = Config.SECRET_KEY
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = Config.SECURE_COOKIES
if Config.SECRET_KEY == "dev-secret-change-me":
logger.warning(
"SECRET_KEY is set to the default value — session cookies are insecure. "
"Set SECRET_KEY or SECRET_KEY_FILE for production use."
)
if not Config.TRUST_PROXY_HEADERS:
logger.warning(
"TRUST_PROXY_HEADERS is not set. If this instance is behind a reverse proxy "
"(nginx, Caddy, Traefik) set TRUST_PROXY_HEADERS=true so rate limiting uses "
"real client IPs rather than the proxy IP."
)
app.register_blueprint(admin_bp)
app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(journal_bp)
app.register_blueprint(export_bp)
app.register_blueprint(images_bp)
app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(fable_mcp_dist_bp)
app.register_blueprint(projects_bp)
app.register_blueprint(push_bp)
app.register_blueprint(quick_capture_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(task_logs_bp)
app.register_blueprint(tasks_bp)
app.register_blueprint(groups_bp)
app.register_blueprint(shares_bp)
app.register_blueprint(notifications_bp)
app.register_blueprint(users_bp)
app.register_blueprint(api_keys_bp)
app.register_blueprint(events_bp)
app.register_blueprint(search_bp)
app.register_blueprint(voice_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(knowledge_bp)
@app.before_request
async def before_request():
g.request_start = time.monotonic()
@app.after_request
async def after_request(response):
duration = time.monotonic() - getattr(g, "request_start", time.monotonic())
duration_ms = round(duration * 1000, 1)
# Downgrade noisy high-frequency / static paths to DEBUG
_quiet = (
request.path in {"/api/health", "/api/chat/status"}
or request.path.startswith(("/static/", "/assets/", "/sw.js", "/manifest.json"))
)
log_fn = logger.debug if _quiet else logger.info
log_fn(
"%s %s %s %.1fms",
request.method,
request.path,
response.status_code,
duration_ms,
)
# Log usage for API requests (skip logs endpoint to avoid recursion)
if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
try:
from fabledassistant.services.logging import log_usage
user = getattr(g, "user", None)
await log_usage(
user_id=user.id if user else None,
username=user.username if user else None,
endpoint=request.path,
method=request.method,
status_code=response.status_code,
duration_ms=duration_ms,
)
except Exception:
logger.debug("Failed to log usage", exc_info=True)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"img-src 'self' data: blob:; "
"connect-src 'self'; "
"font-src 'self' data: https://fonts.gstatic.com; "
"object-src 'none'; "
"base-uri 'self'; worker-src 'self' blob:;"
)
return response
@app.before_serving
async def startup():
import asyncio
from fabledassistant.services.embeddings import backfill_note_embeddings
from fabledassistant.services.generation_buffer import start_cleanup_loop
from fabledassistant.services.llm import ensure_model
from fabledassistant.services.logging import start_log_retention_loop
from fabledassistant.services.notifications import start_notification_loop
from fabledassistant.services.push import ensure_vapid_keys
start_cleanup_loop()
start_log_retention_loop()
start_notification_loop()
ensure_vapid_keys()
async def _warm_model(model: str) -> None:
"""Warm an already-installed model into VRAM (no pull)."""
from fabledassistant.services.llm import keep_alive_for
try:
async with httpx.AsyncClient(timeout=300.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/generate",
json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)},
)
logger.info("Warmed model '%s' into VRAM", model)
except Exception:
logger.warning("Failed to warm model '%s'", model, exc_info=True)
async def _prime_kv_cache(user_id: int, model: str) -> None:
"""Send a minimal chat request to prime Ollama's KV cache with the user's system prompt.
This ensures the next real user message only needs to process its own tokens
rather than the full ~4,650-token system prompt, cutting TTFT from ~25s to <1s.
The num_ctx must match what real requests will use so Ollama doesn't reload.
"""
try:
from fabledassistant.services.llm import build_context, keep_alive_for, pick_num_ctx
from fabledassistant.services.tools import get_tools_for_user
messages, _ = await build_context(
user_id=user_id,
history=[],
current_note_id=None,
user_message=" ",
)
# Include tool schemas so num_ctx matches real chat requests.
tools = await get_tools_for_user(user_id)
num_ctx = pick_num_ctx(messages, tools=tools)
async with httpx.AsyncClient(timeout=120.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"options": {"num_predict": 1, "num_ctx": num_ctx},
"keep_alive": keep_alive_for(model),
},
)
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
except Exception:
logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True)
async def _warm_user_models() -> None:
"""
Pull any user-configured models that are missing from Ollama, then warm
them and prime the KV cache with each user's system prompt.
Handles both default_model (chat) and background_model user overrides.
Falls back silently if no user preferences exist or Ollama is unreachable.
"""
from sqlalchemy import select as sa_select
from fabledassistant.models import async_session
from fabledassistant.models.setting import Setting
# 1. Collect all user model preferences (both chat and background).
try:
async with async_session() as session:
rows = await session.execute(
sa_select(Setting.user_id, Setting.key, Setting.value).where(
Setting.key.in_(["default_model", "background_model"]),
Setting.value.isnot(None),
Setting.value != "",
)
)
settings_rows: list[tuple[int, str, str]] = list(rows)
except Exception:
logger.debug("Could not read user model preferences from DB", exc_info=True)
return
if not settings_rows:
logger.debug("No user model preferences found; skipping warm-up")
return
# 2. Build the set of unique models to ensure, and the list of
# (user_id, chat_model) pairs for KV-cache priming.
all_models: set[str] = set()
user_chat_models: list[tuple[int, str]] = []
for user_id_val, key, model in settings_rows:
all_models.add(model)
if key == "default_model":
user_chat_models.append((user_id_val, model))
# 3. Ask Ollama which models are currently installed.
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status()
raw_installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
installed: set[str] = raw_installed | {
n.removesuffix(":latest") for n in raw_installed if n.endswith(":latest")
}
except Exception:
logger.debug("Could not reach Ollama to check installed models", exc_info=True)
return
# 4. Pull any user-configured models that are missing.
for model in all_models:
if model not in installed:
logger.info("User-configured model '%s' not installed; pulling...", model)
await _pull_model(model)
installed.add(model)
# 5. Warm each unique chat model, then prime KV cache per user.
warmed: set[str] = set()
for user_id_val, model in user_chat_models:
if model in installed:
if model not in warmed:
await _warm_model(model)
warmed.add(model)
await _prime_kv_cache(user_id_val, model)
async def _pull_model(model: str, warm: bool = False) -> None:
try:
await ensure_model(model)
except Exception:
logger.warning(
"Failed to ensure model '%s'",
model,
exc_info=True,
)
return
if warm:
await _warm_model(model)
# Pull supporting models without warming them — embedding model and
# the configured background model load on demand without competing
# for VRAM. The chat model is warmed by _warm_user_models() which
# uses each user's *actual* default_model setting; warming
# Config.OLLAMA_MODEL unconditionally (the OLD behaviour) blasted
# the system default into VRAM ahead of the user's real preference,
# which on a single-GPU setup pushed the user's chat model out
# before they ever sent a message.
asyncio.create_task(_pull_model(Config.OLLAMA_MODEL, warm=False))
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
asyncio.create_task(_pull_model(Config.OLLAMA_BACKGROUND_MODEL, warm=False))
asyncio.create_task(_warm_user_models())
# After models are pulled, backfill embeddings for existing notes.
# Runs in the background so it never blocks the server from accepting requests.
async def _delayed_backfill() -> None:
await asyncio.sleep(30) # Give Ollama time to load the embedding model
try:
await backfill_note_embeddings()
except Exception:
logger.warning("Embedding backfill failed", exc_info=True)
try:
from fabledassistant.services.projects import backfill_project_summaries
await backfill_project_summaries()
except Exception:
logger.warning("Project summary backfill failed", exc_info=True)
asyncio.create_task(_delayed_backfill())
# Start journal scheduler (per-user daily prep generation)
from fabledassistant.services.journal_scheduler import start_journal_scheduler
start_journal_scheduler(asyncio.get_running_loop())
# Start event scheduler (reminders + CalDAV pull sync)
from fabledassistant.services.event_scheduler import start_event_scheduler
start_event_scheduler(asyncio.get_running_loop())
# Start version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
from fabledassistant.services.version_pinning_scheduler import (
start_version_pinning_scheduler,
)
start_version_pinning_scheduler(asyncio.get_running_loop())
# Start curator scheduler (15-min sweep of journal conversations)
from fabledassistant.services.curator_scheduler import start_curator_scheduler
start_curator_scheduler(asyncio.get_running_loop())
# Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var)
from fabledassistant.services.stt import load_stt_model
from fabledassistant.services.tts import load_tts_model
asyncio.create_task(load_stt_model())
asyncio.create_task(load_tts_model())
@app.after_serving
async def shutdown():
from fabledassistant.services.journal_scheduler import stop_journal_scheduler
stop_journal_scheduler()
from fabledassistant.services.event_scheduler import stop_event_scheduler
stop_event_scheduler()
from fabledassistant.services.version_pinning_scheduler import (
stop_version_pinning_scheduler,
)
stop_version_pinning_scheduler()
from fabledassistant.services.curator_scheduler import stop_curator_scheduler
stop_curator_scheduler()
@app.route("/")
async def serve_index():
resp = await make_response(
await send_from_directory(STATIC_DIR, "index.html")
)
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return resp
# File extensions that belong to the SPA or its assets.
# Anything else with an extension is not a valid app path and gets a hard 404.
_SPA_EXTENSIONS = {
"", ".html", ".js", ".css", ".ico", ".png", ".jpg", ".jpeg",
".svg", ".webp", ".woff", ".woff2", ".ttf", ".otf", ".map", ".json",
}
@app.errorhandler(404)
async def handle_404(error):
# Return JSON 404 for API routes
if request.path.startswith("/api/"):
return jsonify({"error": "Not found"}), 404
# Try to serve a real static file first
path = request.path.lstrip("/")
file_path = STATIC_DIR / path
if path and file_path.is_file():
return await send_from_directory(STATIC_DIR, path)
# Reject paths with file extensions the app doesn't serve.
# This turns scanner probes (.php, .asp, .cgi, etc.) into honest 404s
# instead of serving them the Vue SPA with a misleading 200.
from pathlib import PurePosixPath
suffix = PurePosixPath(request.path).suffix.lower()
if suffix not in _SPA_EXTENSIONS:
return "", 404
# SPA fallback for clean client-side routes (/notes/123, /chat, etc.)
resp = await make_response(
await send_from_directory(STATIC_DIR, "index.html")
)
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return resp
@app.errorhandler(500)
async def handle_500(error):
logger.exception("Internal server error on %s %s", request.method, request.path)
try:
from fabledassistant.services.logging import log_error
user = getattr(g, "user", None)
await log_error(
user_id=user.id if user else None,
username=user.username if user else None,
endpoint=request.path,
method=request.method,
error_type=type(error).__name__,
error_message=str(error),
traceback=tb_module.format_exc(),
)
except Exception:
logger.debug("Failed to log error", exc_info=True)
if request.path.startswith("/api/"):
return jsonify({"error": "Internal server error"}), 500
return "Internal Server Error", 500
return app