a2ba90160c
Project view: - Add inline status advance buttons on kanban task cards (todo→in_progress, in_progress→done); buttons reveal on hover, stop link navigation Task viewer: - Back button navigates to task's project instead of /tasks when project_id set - Esc key navigates to project (or /tasks); blurs focused element first Quick capture: - Use user's configured model instead of hardcoded Config.OLLAMA_MODEL - Remove create_project from classifier prompt (tool not offered, caused task-shaped inputs to silently fall through to note fallback) Briefing scheduler: - Fix get_event_loop() → get_running_loop() so background thread uses the correct hypercorn event loop (jobs were scheduling but never executing) - Suppress bare greeting when both LLM synthesis lanes return empty RSS feed UI (SettingsView): - Show last-fetched age, category badge, and feed URL per row - Category input field when adding a feed - Refresh all button: fetches latest items, reloads list, toasts with count - Enter key submits add-feed form; better empty-state hint with example feeds Weather tool: - Accept any city/region name in addition to 'home'/'work'/'all' - Geocodes via Nominatim + fetches live from Open-Meteo for arbitrary queries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
162 lines
6.6 KiB
Python
162 lines
6.6 KiB
Python
"""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
|
||
|
||
from fabledassistant.services.settings import get_setting
|
||
model = await get_setting(uid, "default_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
|