Files
FabledScribe/src/fabledassistant/routes/quick_capture.py
T
bvandeusen 0becc1439b fix(llm): correct context sizing, honor think requests, broaden delete
Three related fixes uncovered while benchmarking qwen3:14b against 8b:

- pick_num_ctx was only counting message content, missing the ~15K
  tokens of tool schemas. num_ctx=8192 was being selected while actual
  prompt_tokens hit 14K+, causing silent prompt truncation on every
  tool-using request. Now includes json.dumps(tools) in the estimate.
  KV cache priming in app.py and routes/settings.py also fetches tools
  so the primed num_ctx matches what real chat requests will use.

- _should_think's heuristic classifier was overriding explicit
  think=true requests from the frontend toggle and MCP, gating on
  message length and regex patterns. Now a pass-through — the caller
  is the source of truth. quick_capture hardcodes think=False since
  it's a fast classification path that was relying on the old gating.

- delete_note description only mentioned "note or task", so the model
  refused to call it for entries created by save_person / save_place /
  create_list. Description now explicitly lists all five note_types it
  handles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:32:52 -04:00

112 lines
4.6 KiB
Python

"""Quick-capture endpoint for mobile/external clients.
POST /api/quick-capture — sends text through the main LLM tool-calling pipeline
and returns a single synchronous JSON response. No SSE, no conversation ID.
"""
import logging
from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.config import Config
from fabledassistant.services.llm import stream_chat_with_tools
from fabledassistant.services.tools import execute_tool, get_tools_for_user
logger = logging.getLogger(__name__)
quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-capture")
# Tools offered to the quick-capture endpoint. Excludes destructive ops,
# read-only queries, and conversational-only tools.
_CAPTURE_TOOL_NAMES = {"create_note", "create_event", "update_note", "research_topic"}
_SYSTEM_PROMPT = """\
Today is {today}. You are a quick-capture assistant. The user has sent a short \
snippet from their mobile device. Create the appropriate item — note, task, or \
calendar event — using the available tools. Always call a tool; never reply \
conversationally."""
@quick_capture_bp.route("", methods=["POST"])
@login_required
async def quick_capture_route():
"""Classify text via native tool-calling and create the appropriate item."""
uid = get_current_user_id()
data = await request.get_json(silent=True) or {}
text = data.get("text", "").strip()
if not text:
return jsonify({"error": "text is required"}), 400
from fabledassistant.services.settings import get_setting
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
all_tools = await get_tools_for_user(uid)
capture_tools = [
t for t in all_tools if t.get("function", {}).get("name") in _CAPTURE_TOOL_NAMES
]
messages = [
{"role": "system", "content": _SYSTEM_PROMPT.format(today=date.today().isoformat())},
{"role": "user", "content": text},
]
# Quick capture is a fast classification path — never think.
tool_calls: list[dict] = []
try:
async for chunk in stream_chat_with_tools(messages, model, tools=capture_tools, think=False, num_ctx=4096):
if chunk.type == "tool_calls" and chunk.tool_calls:
tool_calls = chunk.tool_calls
except Exception:
logger.warning("Quick-capture LLM call failed for uid=%d", uid, exc_info=True)
if tool_calls:
tc = tool_calls[0]
tool_name = tc.get("function", {}).get("name", "")
arguments = tc.get("function", {}).get("arguments", {})
if tool_name == "research_topic" and Config.searxng_enabled():
from fabledassistant.services.research import run_research_pipeline
topic = arguments.get("topic", text)
try:
note = await run_research_pipeline(topic, uid, model)
logger.info("Quick-capture uid=%d: research note id=%d '%s'", uid, note.id, note.title)
return jsonify({
"success": True,
"type": "note",
"message": f"Research note created: {note.title}",
"data": {"id": note.id, "title": note.title},
})
except Exception as exc:
logger.exception("Quick-capture research failed: %s", topic)
return jsonify({"error": f"Research failed: {exc}"}), 500
result = await execute_tool(uid, tool_name, arguments)
if result.get("success"):
item_type = result.get("type", "note")
title = (result.get("data") or {}).get("title", "")
logger.info("Quick-capture uid=%d: %s '%s'", uid, item_type, title)
return jsonify({
"success": True,
"type": item_type,
"message": f"{item_type.capitalize()}: {title}",
"data": result.get("data"),
})
logger.warning("Quick-capture uid=%d: tool '%s' failed: %s", uid, tool_name, result.get("error"))
# Fallback: create a plain note with the raw text
result = await execute_tool(uid, "create_note", {"title": text[:80], "body": text})
if result.get("success"):
title = (result.get("data") or {}).get("title", "")
logger.info("Quick-capture uid=%d: fallback note '%s'", uid, title)
return jsonify({
"success": True,
"type": "note",
"message": f"Note created: {title}",
"data": result.get("data"),
"fallback": True,
})
return jsonify({"error": "Failed to create item"}), 500