"""Quick-capture endpoint for mobile/external clients. POST /api/quick-capture — classifies natural-language text and creates the appropriate item (note, task, calendar event, todo) in a single synchronous request. No SSE, no conversation ID, no streaming. """ import json import logging import re from quart import Blueprint, jsonify, request from fabledassistant.auth import get_current_user_id, login_required from fabledassistant.config import Config from fabledassistant.services.intent import classify_capture_intent from fabledassistant.services.llm import generate_completion 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 classifier. Excludes destructive ops # (delete_*) and read-only queries — worst-case fallback is a plain note. _CAPTURE_TOOL_NAMES = {"create_note", "create_task", "create_event", "update_note", "research_topic"} _NOTE_PROCESS_PROMPT = """\ You are a note-taking assistant. The user has sent a quick-capture snippet. \ Transform it into a well-formed note. Respond with ONLY a JSON object — no other text, no code fences: {{"title": "short descriptive title", "body": "note content in markdown"}} Rules: - title: 3–8 words, a genuine summary — do NOT copy the input verbatim - body: process the input thoughtfully: - Lists of items → formatted bullet list - A stream-of-thought or observation → clean prose, lightly organised - Raw notes or fragments → organised paragraphs with a brief intro line - URLs → include the URL and a one-sentence description of what it points to - Preserve ALL information from the original; do not invent new facts - Use markdown formatting (##, -, **, etc.) where it aids readability - Keep it concise — do not pad with filler""" async def _process_note(text: str, model: str) -> tuple[str, str]: """Use the main model to transform raw capture text into a title + body. Returns (title, body). Falls back to (truncated text, full text) on any failure. """ messages = [ {"role": "system", "content": _NOTE_PROCESS_PROMPT}, {"role": "user", "content": text}, ] try: raw = await generate_completion(messages, model, max_tokens=1024, num_ctx=4096) raw = raw.strip() raw = re.sub(r"^```(?:json)?\s*", "", raw) raw = re.sub(r"\s*```$", "", raw).strip() parsed = json.loads(raw) title = str(parsed.get("title", "")).strip() or text[:60] body = str(parsed.get("body", "")).strip() or text return title, body except Exception: logger.warning("Note processing LLM call failed, using raw text", exc_info=True) fallback_title = text if len(text) <= 80 else text[:77] + "..." return fallback_title, text @quick_capture_bp.route("", methods=["POST"]) @login_required async def quick_capture_route(): """Classify text and create the appropriate item, returning a single JSON response.""" 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 model = Config.OLLAMA_MODEL # Build tool list for this user, then restrict to capture-only operations. 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 ] intent = await classify_capture_intent(text, capture_tools, model) if intent.should_execute: # research_topic bypasses execute_tool — run the pipeline directly if intent.tool_name == "research_topic" and Config.searxng_enabled(): from fabledassistant.services.research import run_research_pipeline topic = intent.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 for topic: %s", topic) return jsonify({"error": f"Research failed: {exc}"}), 500 # For notes, run a second LLM pass to generate a proper title and # well-formed body rather than using the raw capture text verbatim. if intent.tool_name == "create_note": title, body = await _process_note(text, model) intent.arguments["title"] = title intent.arguments["body"] = body result = await execute_tool(uid, intent.tool_name, intent.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' via intent '%s'", uid, item_type, title, intent.tool_name, ) 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' returned failure: %s", uid, intent.tool_name, result.get("error"), ) # Fall through to plain-note fallback # Fallback: classify_capture_intent returned no-tool (e.g. LLM parse failure). # Still process the text through the note enrichment pass. fallback_title, fallback_body = await _process_note(text, model) result = await execute_tool( uid, "create_note", {"title": fallback_title, "body": fallback_body} ) if result.get("success"): title = (result.get("data") or {}).get("title", "") logger.info( "Quick-capture uid=%d: fallback note created '%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