diff --git a/src/fabledassistant/routes/quick_capture.py b/src/fabledassistant/routes/quick_capture.py index 7de75c1..37bc136 100644 --- a/src/fabledassistant/routes/quick_capture.py +++ b/src/fabledassistant/routes/quick_capture.py @@ -5,13 +5,16 @@ 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__) @@ -22,6 +25,48 @@ quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-c # (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 @@ -66,6 +111,14 @@ async def quick_capture_route(): 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": + model = Config.OLLAMA_MODEL + 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") @@ -86,10 +139,10 @@ async def quick_capture_route(): ) # Fall through to plain-note fallback - # Fallback: create a plain note with the raw text as the body. - # Always preserve the full text as the body; derive a short title. - fallback_title = text if len(text) <= 80 else text[:77] + "..." - fallback_body = text + # Fallback: classify_capture_intent returned no-tool (e.g. LLM parse failure). + # Still process the text through the note enrichment pass. + model = Config.OLLAMA_MODEL + fallback_title, fallback_body = await _process_note(text, model) result = await execute_tool( uid, "create_note", {"title": fallback_title, "body": fallback_body}