Files
FabledScribe/src/fabledassistant/routes/chat.py
T
bvandeusen fdb0f10848 fix(chat,curator): unstick chat from silent generation crashes; curator only sees new messages
Two related reliability fixes.

1. routes/chat.py — guard run_generation against uncaught exceptions.

run_generation is launched with asyncio.create_task(); any exception
raised inside the coroutine is silently swallowed by the event loop,
the buffer stays in GenerationState.RUNNING forever, and every
subsequent POST /api/chat/conversations/<id>/messages returns 409
'Generation already in progress' — locking the user out of the chat
with no log trail.

Observed in dev 2026-05-22: assistant message 768 created at 20:36:59
with status=generating, stayed in that state for an hour+, and four
follow-up message attempts returned 409 instantly. The generation
task hung before any internal log line could fire, so the only
diagnostic was the 409 responses themselves.

Wrap run_generation in _run_generation_guarded() that catches
exceptions, logs with full traceback, transitions the buffer to
ERRORED, emits a final 'done' SSE event so any active stream
client closes cleanly, and marks the assistant message status=error
in the DB. After this, a stuck conversation recovers on its own
the next time the user sends a message — no manual DB poke needed.

2. services/curator_scheduler.py — pass last_curator_run_at as 'since'
to the curator so each sweep only sees messages added after the
previous successful pass.

Previously the scheduler called run_curator_for_conversation(conv_id)
with no 'since' argument, so the curator defaulted to its 24h
lookback window. Within an active journal session that meant every
15-min sweep re-extracted beats from messages already captured
on prior sweeps — producing duplicate moments.

_candidate_conversations() now returns (conv_id, last_curator_run_at)
tuples; _sweep() threads the timestamp through. First-run case
(last_curator_run_at IS NULL) falls back to the curator's default
24h window, which is what we want — process recent backlog on
first contact, then only deltas after.

Manual trigger path (POST /api/journal/curator/run/<conv_id>) is
intentionally NOT changed; it still passes since=None so the
24h re-sweep behaviour is preserved for ad-hoc 'reprocess today'
clicks from the UI.

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

549 lines
21 KiB
Python

import asyncio
import json
import logging
import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
bulk_delete_conversations,
create_conversation,
delete_conversation,
get_conversation,
list_conversations,
save_response_as_note,
summarize_conversation_as_note,
update_conversation,
)
from fabledassistant.services.generation_buffer import (
GenerationState,
create_buffer,
get_buffer,
)
from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.notes import get_notes_by_ids
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
@chat_bp.route("/conversations", methods=["GET"])
@login_required
async def list_conversations_route():
uid = get_current_user_id()
limit, offset = parse_pagination()
conv_type = request.args.get("type", "chat")
conversations, total = await list_conversations(uid, limit=limit, offset=offset, conv_type=conv_type)
return jsonify({
"conversations": conversations,
"total": total,
})
@chat_bp.route("/conversations/bulk-delete", methods=["POST"])
@login_required
async def bulk_delete_conversations_route():
uid = get_current_user_id()
data = await request.get_json()
ids = data.get("ids", []) if isinstance(data, dict) else []
if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids):
return jsonify({"error": "ids must be a list of integers"}), 400
count = await bulk_delete_conversations(uid, ids)
return jsonify({"deleted": count})
@chat_bp.route("/conversations", methods=["POST"])
@login_required
async def create_conversation_route():
uid = get_current_user_id()
data = await request.get_json(force=True, silent=True) or {}
title = data.get("title", "")
model = data.get("model", Config.OLLAMA_MODEL)
conversation_type = data.get("conversation_type", "chat")
# Only allow known types to prevent accidental misuse
if conversation_type not in ("chat", "mcp", "voice"):
conversation_type = "chat"
conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type)
return jsonify(conv.to_dict()), 201
@chat_bp.route("/conversations/<int:conv_id>", methods=["GET"])
@login_required
async def get_conversation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
result = conv.to_dict()
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
result["messages"] = []
for m in conv.messages:
msg_dict = m.to_dict()
if m.context_note_id and m.context_note_id in note_map:
msg_dict["context_note_title"] = note_map[m.context_note_id].title
result["messages"].append(msg_dict)
return jsonify(result)
@chat_bp.route("/conversations/<int:conv_id>", methods=["DELETE"])
@login_required
async def delete_conversation_route(conv_id: int):
uid = get_current_user_id()
deleted = await delete_conversation(uid, conv_id)
if not deleted:
return not_found("Conversation")
return "", 204
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
@login_required
async def update_conversation_route(conv_id: int):
from fabledassistant.services.chat import _UNSET
uid = get_current_user_id()
data = await request.get_json()
title = data.get("title")
model = data.get("model")
rag_project_id = data.get("rag_project_id", _UNSET)
if title is None and model is None and rag_project_id is _UNSET:
return jsonify({"error": "title, model, or rag_project_id is required"}), 400
conv = await update_conversation(uid, conv_id, title=title, model=model, rag_project_id=rag_project_id)
if conv is None:
return not_found("Conversation")
return jsonify(conv.to_dict())
@chat_bp.route("/conversations/<int:conv_id>/messages", methods=["POST"])
@login_required
async def send_message_route(conv_id: int):
"""Start generation: save user message, launch background task, return 202."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
data = await request.get_json()
content = data.get("content", "").strip()
if not content:
return jsonify({"error": "content is required"}), 400
context_note_id = data.get("context_note_id")
include_note_ids = data.get("include_note_ids") or []
excluded_note_ids = data.get("excluded_note_ids") or []
think = bool(data.get("think", False))
rag_project_id = data.get("rag_project_id") or None
workspace_project_id = data.get("workspace_project_id") or None
user_timezone = data.get("user_timezone") or None
if not user_timezone:
user_timezone = await get_setting(uid, "user_timezone") or None
effective_rag_project_id = workspace_project_id or rag_project_id
# Reject if generation already running for this conversation
existing = get_buffer(conv_id)
if existing and existing.state == GenerationState.RUNNING:
return jsonify({"error": "Generation already in progress"}), 409
# Save user message
await add_message(conv_id, "user", content, context_note_id=context_note_id)
# Create placeholder assistant message
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
try:
buf = create_buffer(conv_id, assistant_msg.id)
except RuntimeError:
return jsonify({"error": "Generation already in progress"}), 409
# Build history from existing messages (excluding system and the placeholder)
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
# Launch background generation task (context building happens inside the task).
#
# Wrap in a top-level guard: `run_generation` is fire-and-forget via
# asyncio.create_task. Without a wrapper, an unhandled exception inside
# the coroutine is swallowed by the event loop, the buffer stays in
# GenerationState.RUNNING forever, and every subsequent POST to this
# conversation returns 409 — locking the user out of the chat surface
# with no log trail. Observed in production 2026-05-22 where the
# generation hung before any internal log line could fire.
async def _run_generation_guarded():
try:
await run_generation(
buf, history, model,
uid, conv_id, conv.title, content,
context_note_id=context_note_id,
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
think=think,
rag_project_id=effective_rag_project_id,
workspace_project_id=workspace_project_id,
user_timezone=user_timezone,
voice_mode=(conv.conversation_type == "voice"),
)
except Exception:
logger.exception(
"run_generation crashed for conv %d msg %d (model=%s); "
"transitioning buffer to FAILED so the route can be used again",
conv_id, assistant_msg.id, model,
)
try:
buf.state = GenerationState.ERRORED
buf.append_event(
"done",
{"done": True, "error": "Generation crashed; see server logs"},
)
except Exception:
logger.exception("Failed to mark buffer ERRORED for conv %d", conv_id)
try:
from fabledassistant.services.generation_task import _update_message
await _update_message(
assistant_msg.id,
"",
"error",
tool_calls=None,
)
except Exception:
logger.exception(
"Failed to mark assistant message %d as error after crash",
assistant_msg.id,
)
asyncio.create_task(_run_generation_guarded())
return jsonify({
"assistant_message_id": assistant_msg.id,
"status": "generating",
}), 202
@chat_bp.route("/conversations/<int:conv_id>/generation/stream", methods=["GET"])
@login_required
async def generation_stream_route(conv_id: int):
"""SSE endpoint that tails the generation buffer for a conversation."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None:
return jsonify({"error": "No active generation"}), 404
# Determine starting point from Last-Event-ID header or query param
last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
try:
last_id = int(last_id_str) if last_id_str is not None else -1
except (ValueError, TypeError):
last_id = -1
async def stream():
cursor = last_id
while True:
# Replay any buffered events past the cursor
pending = buf.events_after(cursor)
for event in pending:
yield buf.format_sse(event)
cursor = event.index
# If generation is done and all events delivered, close stream
if buf.state != GenerationState.RUNNING:
break
# Wait for new events or send keepalive on timeout
got_new = await buf.wait_for_event(cursor, timeout=15.0)
if not got_new:
yield ": keepalive\n\n"
# Final drain: deliver any events appended between the last yield
# and the state check above (e.g. the 'done' event).
for event in buf.events_after(cursor):
yield buf.format_sse(event)
return Response(
stream(),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@chat_bp.route("/conversations/<int:conv_id>/generation/confirm", methods=["POST"])
@login_required
async def confirm_generation_route(conv_id: int):
"""Resolve a pending tool confirmation (accept or decline)."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
return jsonify({"error": "No active generation"}), 404
if buf.confirmation_future is None or buf.confirmation_future.done():
return jsonify({"error": "No pending tool confirmation"}), 409
data = await request.get_json(force=True, silent=True) or {}
decision = bool(data.get("confirmed", False))
buf.confirmation_future.set_result(decision)
return jsonify({"status": "ok", "confirmed": decision})
@chat_bp.route("/conversations/<int:conv_id>/generation/cancel", methods=["POST"])
@login_required
async def cancel_generation_route(conv_id: int):
"""Cancel an active generation for a conversation."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
return jsonify({"error": "No active generation"}), 404
buf.cancel_event.set()
return jsonify({"status": "cancelled"})
@chat_bp.route("/messages/<int:message_id>/save-as-note", methods=["POST"])
@login_required
async def save_message_as_note_route(message_id: int):
uid = get_current_user_id()
try:
note = await save_response_as_note(uid, message_id)
return jsonify(note), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
@chat_bp.route("/conversations/<int:conv_id>/summarize", methods=["POST"])
@login_required
async def summarize_conversation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
try:
note = await summarize_conversation_as_note(uid, conv_id, model)
return jsonify(note), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
@chat_bp.route("/ps", methods=["GET"])
@login_required
async def running_models_route():
"""Return currently loaded (hot) models from Ollama."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/ps")
resp.raise_for_status()
data = resp.json()
models = [
{
"name": m["name"],
"size": m.get("size", 0),
"size_vram": m.get("size_vram", 0),
"expires_at": m.get("expires_at", ""),
}
for m in data.get("models", [])
]
return jsonify({"models": models})
except Exception as e:
logger.debug("Failed to query Ollama /api/ps: %s", e)
return jsonify({"models": []})
@chat_bp.route("/warm", methods=["POST"])
@login_required
async def warm_model_route():
"""Pre-load a model into Ollama memory."""
data = await request.get_json(force=True, silent=True) or {}
model = data.get("model", "").strip()
if not model:
return jsonify({"error": "model is required"}), 400
async def _warm():
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", model)
except Exception as e:
logger.warning("Failed to warm model %s: %s", model, e)
asyncio.create_task(_warm())
return jsonify({"status": "warming"}), 202
@chat_bp.route("/status", methods=["GET"])
@login_required
async def chat_status_route():
"""Check Ollama availability, model installation, and model load state."""
uid = get_current_user_id()
default_model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
# Guard against empty-string rows written by older code when user selected "default"
if not default_model:
default_model = Config.OLLAMA_MODEL
result = {
"ollama": "unavailable",
"model": "not_found",
"default_model": default_model,
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Query installed models and loaded models in parallel
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
if isinstance(tags_resp, Exception):
logger.debug("Ollama /api/tags failed: %s", tags_resp)
else:
tags_resp.raise_for_status()
result["ollama"] = "available"
model_names = {m["name"] for m in tags_resp.json().get("models", [])}
base = default_model.removesuffix(":latest")
if default_model in model_names or f"{base}:latest" in model_names or base in model_names:
# Installed — now check if currently loaded in memory
result["model"] = "cold"
if not isinstance(ps_resp, Exception):
try:
ps_resp.raise_for_status()
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
if default_model in loaded_names or f"{base}:latest" in loaded_names or base in loaded_names:
result["model"] = "loaded"
except Exception:
logger.debug("Ollama /api/ps check failed", exc_info=True)
except Exception:
logger.debug("Ollama status check failed", exc_info=True)
return jsonify(result)
@chat_bp.route("/models", methods=["GET"])
@login_required
async def list_models_route():
try:
async with httpx.AsyncClient(timeout=10.0) as client:
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
loaded_names: set[str] = set()
if not isinstance(ps_resp, Exception):
try:
ps_resp.raise_for_status()
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
except Exception:
pass
models = []
if not isinstance(tags_resp, Exception):
tags_resp.raise_for_status()
for m in tags_resp.json().get("models", []):
models.append({
"name": m["name"],
"size": m.get("size", 0),
"modified_at": m.get("modified_at", ""),
"loaded": m["name"] in loaded_names,
})
return jsonify({"models": models})
except Exception as e:
logger.warning("Failed to list Ollama models: %s", e)
return jsonify({"models": [], "error": str(e)}), 200
@chat_bp.route("/models/pull", methods=["POST"])
@admin_required
async def pull_model_route():
"""Pull a model from Ollama, streaming progress via SSE."""
data = await request.get_json()
model_name = data.get("model", "").strip()
if not model_name:
return jsonify({"error": "model is required"}), 400
async def generate():
try:
async with httpx.AsyncClient(timeout=1800.0) as client:
async with client.stream(
"POST",
f"{Config.OLLAMA_URL}/api/pull",
json={"name": model_name},
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line.strip():
continue
progress = json.loads(line)
event_data = json.dumps(progress)
yield f"data: {event_data}\n\n"
yield f"data: {json.dumps({'status': 'success'})}\n\n"
except Exception as e:
logger.exception("Error pulling model %s", model_name)
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return Response(
generate(),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@chat_bp.route("/models/delete", methods=["POST"])
@admin_required
async def delete_model_route():
"""Delete a model from Ollama."""
data = await request.get_json()
model_name = data.get("model", "").strip()
if not model_name:
return jsonify({"error": "model is required"}), 400
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(
"DELETE",
f"{Config.OLLAMA_URL}/api/delete",
json={"name": model_name},
)
resp.raise_for_status()
return jsonify({"status": "deleted", "model": model_name})
except httpx.HTTPStatusError as e:
logger.warning("Failed to delete model %s: %s", model_name, e)
return jsonify({"error": f"Failed to delete model: {e.response.status_code}"}), 400
except Exception as e:
logger.warning("Failed to delete model %s: %s", model_name, e)
return jsonify({"error": str(e)}), 500