Phase 10: CalDAV full lifecycle, update_note, dashboard inline streaming, keyboard shortcuts

Backend:
- caldav.py: Full event lifecycle — update_event, delete_event; VTODO suite —
  create_todo, list_todos, complete_todo, delete_todo; list_calendars; timezone
  support via ZoneInfo; reminders via VALARM; attendees; multi-calendar search
  (_get_all_calendars scans all calendars when no specific one is configured)
- tools.py: New update_note tool (find by title + replace/append modes),
  7 new CalDAV tool definitions, corresponding execute_tool cases
- llm.py: Update system prompt — add update_note guidance, full CalDAV action list
- intent.py: Confidence scoring (high/medium/low) + should_execute property;
  conversation history support for anaphora resolution; routing rules for
  update/delete events, todos, update_note vs create_note disambiguation,
  time-period → list_events (not search_events), reminder_minutes conversion
- generation_task.py: Parallel fetch of tools + intent_model setting; dedicated
  intent model (OLLAMA_INTENT_MODEL env var or per-user intent_model setting)
- config.py: Add OLLAMA_INTENT_MODEL env var

Frontend:
- HomeView.vue: Inline streaming response (no navigation); quick action chips;
  isConversational computed — prominent "Continue this conversation" CTA when
  no tool calls; auto-focus chat input on mount via chatInputRef
- DashboardChatInput.vue: defineExpose({ focus }) for external focus control
- ChatView.vue: Escape key handler — close picker → close sidebar → clear
  textarea → navigate home; onUnmounted cleanup
- App.vue: Global ? key shortcut toggles keyboard shortcuts overlay; shared
  state via useShortcuts composable; Transition animation
- AppHeader.vue: ? button for shortcuts overlay discoverability
- useShortcuts.ts (new): Shared showShortcuts ref + open/close/toggle helpers
- ToolCallCard.vue: note_updated, event_updated, event_deleted, calendars,
  todo, todos, todo_completed, todo_deleted label cases + render blocks
- SettingsView.vue: Intent model field + caldav_timezone setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 22:04:41 -05:00
parent 75560dee4e
commit 70cba72a80
15 changed files with 1569 additions and 71 deletions
+55 -10
View File
@@ -21,6 +21,12 @@ logger = logging.getLogger(__name__)
class IntentResult:
tool_name: str | None = None # None = no tool, just chat
arguments: dict = field(default_factory=dict)
confidence: str = "high" # "high", "medium", or "low"
@property
def should_execute(self) -> bool:
"""True if a tool was identified with sufficient confidence."""
return self.tool_name is not None and self.confidence != "low"
def _build_tool_summary(tools: list[dict]) -> str:
@@ -45,8 +51,9 @@ def _build_tool_summary(tools: list[dict]) -> str:
_SYSTEM_PROMPT_TEMPLATE = """\
You are an intent classifier. Given a user message, decide whether it \
requires calling one of the available tools or is just general chat.
You are an intent classifier. Given a user message (and recent conversation \
history for context), decide whether it requires calling one of the available \
tools or is just general chat.
Today's date is {today}.
@@ -54,15 +61,33 @@ Available tools:
{tool_summary}
Respond with ONLY a JSON object, no other text:
- If a tool should be called: {{"tool": "tool_name", "arguments": {{...}}}}
- If it's general chat: {{"tool": null}}
- If a tool should be called: {{"tool": "tool_name", "arguments": {{...}}, "confidence": "high"|"medium"|"low"}}
- If it's general chat: {{"tool": null, "confidence": "high"}}
Confidence levels:
- "high": the intent is clear and all required arguments are unambiguous
- "medium": probably requires the tool but some argument is uncertain or inferred
- "low": uncertain whether this needs a tool at all, or the message is too ambiguous to act on
Rules:
- Use recent conversation history to resolve references like "it", "that event", "the meeting", "move it", etc.
- For dates like "tomorrow", "next Friday", "in 3 days", resolve them to YYYY-MM-DD format.
- For datetime parameters, use ISO 8601 format (e.g. 2026-09-30T14:00:00).
- Only include arguments the user actually specified or that can be clearly inferred.
- Infer reasonable defaults: birthdays and holidays are all-day + yearly recurring; "weekly meeting" is weekly recurring.
- Use descriptive titles: "My Birthday" not just "Birthday", "Team Standup" not just "Meeting".
- "add to", "update", "edit", "expand", "flesh out", "modify", "append to", "continue writing" a note → use update_note with query=<note title from context> and mode="append" for additions or mode="replace" for full rewrites.
- If a note was created earlier in the conversation and the user provides more content for it, use update_note (not create_note).
- Use create_note ONLY when the user explicitly wants a brand new note that doesn't already exist.
- "update", "change", "move", "reschedule" an event → use update_event.
- "delete", "cancel", "remove" an event → use delete_event.
- "which calendars", "list calendars", "my calendars" → use list_calendars.
- "create a calendar todo", "add a CalDAV todo" → use create_todo. Default to create_task for general tasks unless the user explicitly mentions CalDAV or calendar todo.
- "show calendar todos", "list calendar todos" → use list_todos.
- "completed/finished calendar todo" → use complete_todo.
- "delete/remove calendar todo" → use delete_todo.
- "remind me X minutes/hours before" an event → convert to reminder_minutes parameter on create_event.
- When the user asks about events in a time period (e.g. "events in September", "what's on next week", "my schedule for March"), use list_events with date_from/date_to covering that period. Do NOT use search_events for time-based queries — search_events is only for keyword matching against event titles.
- Do NOT wrap the JSON in markdown code fences."""
@@ -70,10 +95,14 @@ async def classify_intent(
user_message: str,
tools: list[dict],
model: str,
history: list[dict] | None = None,
) -> IntentResult:
"""Classify user intent via a fast non-streaming LLM call.
Returns an IntentResult. On any failure, returns IntentResult(tool_name=None)
history is a list of recent {role, content} messages (user/assistant only,
no system messages) for resolving anaphoric references.
Returns an IntentResult. On any failure, returns IntentResult(tool_name=None)
so the caller falls through to the normal streaming path.
"""
if not tools:
@@ -82,16 +111,25 @@ async def classify_intent(
tool_summary = _build_tool_summary(tools)
today = date_type.today().isoformat()
messages = [
messages: list[dict] = [
{
"role": "system",
"content": _SYSTEM_PROMPT_TEMPLATE.format(
today=today, tool_summary=tool_summary
),
},
{"role": "user", "content": user_message},
]
# Inject recent history turns so the model can resolve references
if history:
for turn in history:
role = turn.get("role")
content = turn.get("content", "")
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": user_message})
try:
raw = await generate_completion(messages, model)
except Exception:
@@ -124,8 +162,12 @@ def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
return IntentResult()
tool_name = parsed.get("tool")
confidence = parsed.get("confidence", "high")
if confidence not in ("high", "medium", "low"):
confidence = "high"
if tool_name is None:
return IntentResult()
return IntentResult(confidence=confidence)
# Validate tool name against available tools
valid_names = {
@@ -139,8 +181,11 @@ def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
if not isinstance(arguments, dict):
arguments = {}
logger.info("Intent classified: tool=%s, args=%s", tool_name, json.dumps(arguments)[:200])
return IntentResult(tool_name=tool_name, arguments=arguments)
logger.info(
"Intent classified: tool=%s, confidence=%s, args=%s",
tool_name, confidence, json.dumps(arguments)[:200],
)
return IntentResult(tool_name=tool_name, arguments=arguments, confidence=confidence)
def _try_json(text: str) -> dict | list | None: