Quick-capture notes: enrich content via main model

Add _process_note() — a second LLM pass using the main model that
transforms raw capture text into a well-formed note with a genuine
summary title and formatted body. Replaces the previous behaviour of
using the captured text verbatim as both title and body.

The processing prompt instructs the model to:
- Generate a 3-8 word summary title (never a verbatim copy)
- Format the body appropriately: bullet lists for items, clean prose
  for stream-of-thought, organised paragraphs for raw notes/fragments
- Preserve all original information without inventing new facts

The enrichment pass runs for both the intent-classified create_note
path and the fallback path. On LLM/parse failure it degrades safely
to the old verbatim behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 17:57:29 -05:00
parent 2c2874a1cc
commit 1106399883
+57 -4
View File
@@ -5,13 +5,16 @@ appropriate item (note, task, calendar event, todo) in a single synchronous
request. No SSE, no conversation ID, no streaming. request. No SSE, no conversation ID, no streaming.
""" """
import json
import logging import logging
import re
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.config import Config from fabledassistant.config import Config
from fabledassistant.services.intent import classify_capture_intent 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 from fabledassistant.services.tools import execute_tool, get_tools_for_user
logger = logging.getLogger(__name__) 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. # (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"} _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: 38 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"]) @quick_capture_bp.route("", methods=["POST"])
@login_required @login_required
@@ -66,6 +111,14 @@ async def quick_capture_route():
logger.exception("Quick-capture research failed for topic: %s", topic) logger.exception("Quick-capture research failed for topic: %s", topic)
return jsonify({"error": f"Research failed: {exc}"}), 500 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) result = await execute_tool(uid, intent.tool_name, intent.arguments)
if result.get("success"): if result.get("success"):
item_type = result.get("type", "note") item_type = result.get("type", "note")
@@ -86,10 +139,10 @@ async def quick_capture_route():
) )
# Fall through to plain-note fallback # Fall through to plain-note fallback
# Fallback: create a plain note with the raw text as the body. # Fallback: classify_capture_intent returned no-tool (e.g. LLM parse failure).
# Always preserve the full text as the body; derive a short title. # Still process the text through the note enrichment pass.
fallback_title = text if len(text) <= 80 else text[:77] + "..." model = Config.OLLAMA_MODEL
fallback_body = text fallback_title, fallback_body = await _process_note(text, model)
result = await execute_tool( result = await execute_tool(
uid, "create_note", {"title": fallback_title, "body": fallback_body} uid, "create_note", {"title": fallback_title, "body": fallback_body}