Phase 21: Intent-first pipeline, visible ack, KV-stable system prompt
Pipeline changes (generation_task.py, intent.py): - Remove optimistic streaming queue/race (_drain_queue deleted) - Remove _generate_acknowledgment — ack now embedded in intent JSON - Round 0: await intent (~400ms), stream ack immediately as TTFT, then execute tool sequentially; chat-only streams directly - IntentResult.ack: one-sentence acknowledgment, intent max_tokens 200→350 - _parse_intent extracts and trims ack field KV cache stability (llm.py, generation_buffer.py, generation_task.py): - build_context: replace cached_note_ids with include_note_ids - Auto-found notes populate context_meta["auto_notes"] for sidebar but are NOT injected into system prompt (--- Related Notes --- removed) - Explicitly included notes injected as --- Included Notes --- - _conv_note_cache dict + get/set/clear functions removed from generation_buffer.py - All clear_conv_note_cache() calls removed Cold model retry (llm.py): - generate_completion (used by classify_intent) retries on HTTP 500: 3 attempts with 3s/6s delays — prevents intent failure during cold load API + frontend (routes/chat.py, stores/chat.ts, views/ChatView.vue, components/ChatPanel.vue): - exclude_note_ids → include_note_ids throughout - ChatView sidebar: Suggested (auto-found, + to include) + In Context (× to remove) - ChatPanel: remove exclude button from context pills; no IDs passed to sendMessage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+32
-22
@@ -12,7 +12,7 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## Last Updated
|
||||
2026-02-26 — Phase 20: Dedicated tag field (chip input), tags no longer extracted from body
|
||||
2026-02-26 — Phase 21: Intent-first pipeline, visible acknowledgment, KV-stable system prompt
|
||||
|
||||
## Project Overview
|
||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||
@@ -75,11 +75,15 @@ for AI-assisted features.
|
||||
reconnection support. Frontend uses `fetch()` + `ReadableStream`. Simpler than
|
||||
WebSockets; Quart supports async generators natively for SSE.
|
||||
- **Context building server-side:** Backend fetches URL content and searches notes —
|
||||
frontend just sends the message text + optional note ID + optional exclude list.
|
||||
frontend sends the message text + optional context note ID + optional `include_note_ids`.
|
||||
Keyword extraction uses simple word splitting with stopword filtering (no embeddings).
|
||||
`build_context()` returns `(messages, context_meta)` tuple; metadata includes
|
||||
auto-found note IDs/titles sent to frontend via SSE `context` event before streaming.
|
||||
Multi-word search splits terms into per-word ILIKE with AND logic (not adjacent match).
|
||||
Auto-found notes populate the sidebar but are **not** injected into the system prompt
|
||||
automatically — users must click `+` in the sidebar to include them (stable system
|
||||
prompt prefix enables Ollama KV cache reuse). Explicitly included notes appear as
|
||||
`--- Included Notes ---` in the system prompt.
|
||||
- **Reverse proxy required for production:** The app does not terminate TLS. A
|
||||
reverse proxy (Nginx/Traefik/Caddy) must sit in front of port 5000. Do not
|
||||
expose the app directly to the internet.
|
||||
@@ -283,8 +287,8 @@ fabledassistant/
|
||||
│ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming (stream_chat + stream_chat_with_tools), ChatChunk dataclass, URL fetching; uses Config.OLLAMA_NUM_CTX for KV cache window
|
||||
│ │ ├── chat.py # Conversation CRUD with user_id isolation, add_message, save/summarize as note (LLM-titled, chat-tagged)
|
||||
│ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup; supports chat (int keys) and assist (string keys)
|
||||
│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, optimistic streaming + intent racing + tool loop) + run_assist_generation (lightweight, no DB); _drain_queue() async generator helper
|
||||
│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call to detect tool intent before streaming
|
||||
│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, intent-first pipeline + tool loop) + run_assist_generation (lightweight, no DB)
|
||||
│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call; IntentResult has ack field (one-sentence acknowledgment streamed as TTFT)
|
||||
│ │ ├── tools.py # LLM tool definitions (create/delete note+task, update_note w/tag management, get_note, list_notes, search_notes w/type filter, list_tasks, full CalDAV suite incl. search_todos) + execute_tool dispatcher
|
||||
│ │ ├── tag_suggestions.py # LLM-powered tag suggestions: suggest_tags() builds prompt with existing tags, calls generate_completion, parses JSON response
|
||||
│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), todos (create/list/search/update/complete/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search
|
||||
@@ -317,7 +321,7 @@ fabledassistant/
|
||||
│ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, oauthEnabled, localAuthEnabled, login/register/logout/checkAuth/checkHasUsers
|
||||
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
|
||||
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (with toast errors)
|
||||
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), status polling (memory-leak-safe _pollUntilLoaded), running models, model warming, updateConversationModel (with toast errors)
|
||||
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming, includeNoteIds param), status polling (memory-leak-safe _pollUntilLoaded), running models, model warming, updateConversationModel (with toast errors)
|
||||
│ │ ├── settings.ts # App settings: assistantName, defaultModel, installedModels, defaultChatModel, defaultIntentModel, pullModel, deleteModel (with toast errors)
|
||||
│ │ └── toast.ts # Toast notification state (success/error/warning), 4s auto-dismiss, dismiss(id)
|
||||
│ ├── types/
|
||||
@@ -342,7 +346,7 @@ fabledassistant/
|
||||
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
|
||||
│ │ ├── RegisterInviteView.vue # Invitation-based registration: validates token, creates account with pre-set email
|
||||
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, invitations (send/revoke), user list with delete
|
||||
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, persistent context sidebar (right panel, hidden mobile), model selector in header
|
||||
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context sidebar with “In Context” (user-included, ×) + “Suggested” (auto-found, +), model selector in header
|
||||
│ │ ├── HomeView.vue # Chat-first dashboard: quick actions + chat widget (top, full-width), inline response panel, two-column grid (3fr tasks / 2fr notes); task sections: Overdue, Due Today, Due This Week, High Priority, In Progress, Other (capped 10, due-dated first); 8 recent notes; model warming on mount
|
||||
│ │ ├── SettingsView.vue # Settings page: assistant name, chat/intent model dropdowns (populated from installed models), email change (with password confirmation for local-auth users), change password, notifications, CalDAV, SMTP (admin), base URL (admin), data export/restore (admin)
|
||||
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
|
||||
@@ -354,7 +358,7 @@ fabledassistant/
|
||||
│ ├── components/
|
||||
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with IP column + expandable detail rows (expands on ip_address or details)
|
||||
│ │ ├── AppHeader.vue # Nav bar: brand, nav links (incl. admin Logs), status indicator, theme toggle, user info + logout, hamburger menu (mobile)
|
||||
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote/exclude
|
||||
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote (+) only (no exclude)
|
||||
│ │ ├── ModelSelector.vue # Model dropdown (v-model pattern): fetches installed + running models, hot/cold indicators
|
||||
│ │ ├── DashboardChatInput.vue # Inline chat bar: ModelSelector + note picker + textarea + send button; emits submit event
|
||||
│ │ ├── ToolCallCard.vue # Compact card for tool call results (created/deleted task/note, note content, notes list, search results, CalDAV events/todos, errors) + suggested tag pills with apply-on-click
|
||||
@@ -432,7 +436,7 @@ fabledassistant/
|
||||
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
|
||||
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
|
||||
| PATCH | `/api/chat/conversations/:id` | Update conversation title or model (body: `{title?, model?}`) |
|
||||
| POST | `/api/chat/conversations/:id/messages` | Start generation: save user message, launch background task, return 202 (body: `{content, context_note_id?, exclude_note_ids?}`) |
|
||||
| POST | `/api/chat/conversations/:id/messages` | Start generation: save user message, launch background task, return 202 (body: `{content, context_note_id?, include_note_ids?}`) |
|
||||
| GET | `/api/chat/conversations/:id/generation/stream` | SSE endpoint tailing generation buffer; supports `Last-Event-ID` reconnection; emits `context`, `chunk`, `done`, `error` events |
|
||||
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation (sets cancel_event, saves partial content) |
|
||||
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as a new note (LLM-generated title, tagged `chat`) |
|
||||
@@ -565,9 +569,12 @@ When adding a new migration, follow these conventions:
|
||||
- Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup)
|
||||
- Stop generation with partial content preservation
|
||||
- Note-aware context building: current note + keyword search for related notes + URL fetching
|
||||
- **Persistent context sidebar:** Right panel in ChatView accumulates auto-found notes across turns.
|
||||
Manually attached note appears with 📌 pin, clears after send. × excludes from future auto-search.
|
||||
Hidden on mobile (≤768px). Replaces old ephemeral context pills in the message stream.
|
||||
- **Context sidebar (Phase 21):** Right panel in ChatView shows two sections: **Suggested** (auto-found
|
||||
by semantic/keyword search — click `+` to include) and **In Context** (explicitly included by user —
|
||||
click `×` to remove). Removing an included note moves it back to Suggested. Manually attached note
|
||||
appears with 📌 pin in the In Context section, clears after send. Hidden on mobile (≤768px).
|
||||
Auto-found notes are shown as sidebar candidates only — they are NOT injected into the system prompt
|
||||
automatically, keeping the system prompt prefix stable for Ollama KV cache reuse.
|
||||
- Note picker (paperclip) in chat input; attached note title passed to store for optimistic render.
|
||||
`context_note_title` synthesised server-side at conversation load via batch `get_notes_by_ids()`;
|
||||
message badge shows note title instead of "Note #N".
|
||||
@@ -614,21 +621,22 @@ When adding a new migration, follow these conventions:
|
||||
(`search_todos` keyword-filters the todo list — companion to `list_todos`)
|
||||
- **Streaming status transparency:** The backend emits `status` SSE events at each pipeline stage
|
||||
so the user always sees what's happening instead of a blank progress dot. Stages:
|
||||
(1) `"Generating response..."` immediately after context is built (no blocking wait for intent);
|
||||
(2) human-readable tool label (e.g. `"Creating calendar event..."`) before each tool executes
|
||||
(both intent-routed and native); (3) `"Composing response..."` before tool follow-up rounds.
|
||||
(1) intent ack text streamed as a `chunk` event (tool responses — TTFT ~400ms) or
|
||||
`"Generating response..."` status event (chat-only responses);
|
||||
(2) human-readable tool label (e.g. `"Creating calendar event..."`) before each tool executes;
|
||||
(3) `"Composing response..."` before tool follow-up rounds.
|
||||
Frontend: `chat.ts` stores `streamingStatus` ref, cleared on first content chunk or on done/error.
|
||||
`ChatView.vue` shows a pulsing dot + italic label above the content while status is active, then
|
||||
falls back to the blinking cursor when content streams in. `HomeView.vue` dashboard panel shows
|
||||
the status label in place of `...` before any content arrives.
|
||||
- **Intent routing (optimistic streaming):** On the first round, the backend races intent
|
||||
classification against the start of the LLM stream using `asyncio.wait(FIRST_COMPLETED)`.
|
||||
The LLM stream is immediately started into an `asyncio.Queue` while `classify_intent()` runs
|
||||
concurrently. If the stream produces its first token before classification completes, the intent
|
||||
task is cancelled and the user sees tokens immediately (zero blocking for pure chat). If
|
||||
classification finishes first and detects a tool call, the stream is cancelled and the tool
|
||||
executes directly — bypassing the model's native (sometimes unreliable) tool calling API. Falls
|
||||
through to normal streaming when no tool is detected or classification fails.
|
||||
- **Intent routing (intent-first pipeline, Phase 21):** On the first round, `build_context()` and
|
||||
`classify_intent()` run concurrently. Once intent returns (~400ms), the pipeline immediately acts:
|
||||
if a tool is detected, the intent's one-sentence `ack` field is streamed as the first chunk
|
||||
(becoming TTFT), the tool executes, then the main model generates a follow-up response with the
|
||||
tool result. For chat-only responses, the model streams directly with no ack prefix.
|
||||
No optimistic streaming queue or race — eliminates wasted GPU prefill when intent won the race.
|
||||
`IntentResult.ack` (one-sentence acknowledgment) is embedded in the intent JSON output, so no
|
||||
additional LLM call is needed for acknowledgment. Intent model `max_tokens` 350 (was 200).
|
||||
Dedicated intent model configurable via `OLLAMA_INTENT_MODEL` env var (default `qwen2.5:1.5b`)
|
||||
or per-user `intent_model` setting — smaller/faster model for routing. Main model default
|
||||
is `qwen3:latest` (configurable via `OLLAMA_MODEL` env var or per-user `default_model` setting
|
||||
@@ -641,6 +649,8 @@ When adding a new migration, follow these conventions:
|
||||
search_events), update_note vs create_note disambiguation, reminder_minutes conversion,
|
||||
delete_note vs delete_task disambiguation, get_note for "read/show me this note", list_notes for
|
||||
"browse/list notes", tag management via update_note (tag_mode add/remove), search_todos.
|
||||
`generate_completion` (used by intent classifier) retries on HTTP 500 (3 attempts, 3s/6s delays)
|
||||
to handle cold model loading without failing intent classification.
|
||||
- **CalDAV calendar integration:** Per-user CalDAV settings (URL, username, password, calendar name, timezone).
|
||||
LLM tools: `create_event` (all_day, recurrence, timezone, reminder_minutes, attendees, calendar_name),
|
||||
`list_events`, `search_events`, `update_event`, `delete_event`, `list_calendars`,
|
||||
|
||||
Reference in New Issue
Block a user