The intent classifier (Phase 21) is removed from the main chat generation
path. The main model now handles all tool routing natively via Ollama's
structured tool-calling API, eliminating misidentification issues caused
by the small intent model.
Changes:
- generation_task.py: remove classify_intent call, intent_task, _WRITE_TOOLS,
_TOOL_ACTIONS, _INTENT_TRIGGER_WORDS, _should_skip_intent(), and the entire
round-0 intent-first + write-tool confirmation block (~315 lines removed)
- research_topic tool calls are now handled inline in the streaming loop:
runs run_research_pipeline, streams synthesis to buf, then breaks the round
loop (research is still the full response, no model follow-up)
- config.py: raise OLLAMA_NUM_CTX default from 8192 to 16384
The quick-capture dedicated classifier (classify_capture_intent) is unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
- stream_chat and stream_chat_with_tools: remove read=300s per-chunk
timeout, replace with read=None. In httpx streaming mode, the read
timeout applies per-chunk — if Ollama pauses >300s while processing
a large input context before the first token, it raises ReadTimeout,
killing generation and leaving the assistant message as an empty stub.
With read=None the stream is unbounded; connect=30s still guards the
initial connection.
- chat_status_route: increase Ollama status check timeout 5s → 10s.
When Ollama is busy processing a large prompt it can be slow to
respond to /api/tags, causing the status indicator to briefly flip to
"offline" even though generation is running normally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove all 6 CalDAV todo tools (create/list/update/complete/delete/search_todos)
from tools.py definitions, imports, execute_tool branches, intent routing rules,
generation_task labels/actions, and llm.py system prompt hints. CalDAV event
tools remain. Todo functions still exist in caldav.py but are no longer exposed.
- Quick-capture now uses a dedicated classify_capture_intent() with a focused
_CAPTURE_SYSTEM_PROMPT that always routes to a tool (never null). Tool set
expanded: create_note/task/event + update_note + research_topic.
- research_topic in quick-capture calls run_research_pipeline() directly (no SSE
buffer). run_research_pipeline() now accepts buf=None; all buf.append_event
calls are guarded so status events are skipped when no buffer is provided.
- Fallback note now always sets body=text (was empty for texts ≤80 chars).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Quart's send_file uses cache_timeout= not max_age=. The TypeError on
every /api/images/<id> request caused a 500, which the browser rendered
as alt text.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- tools.py: search_images result now includes 'embed' (ready-to-use
markdown image syntax) and 'citation' fields instead of raw 'local_url';
adds 'instructions' field so the model knows to render them verbatim
- llm.py: system prompt now explicitly tells the model to embed images
using the 'embed' field rather than describing or listing URLs
- markdown.ts: explicitly allow src/alt in PURIFY_OPTS_FULL so img tags
are never stripped by DOMPurify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single named volume app_data covering the entire /data directory so
future persistent storage (uploads, exports, etc.) doesn't need
additional volume entries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Images found via SearXNG are fetched server-side, stored on disk, and
served from /api/images/<id> — the user's browser never contacts the
original image host. Original URLs are preserved for citation.
New files:
- alembic/versions/0016_add_image_cache.py — image_cache table
- src/fabledassistant/models/image_cache.py — SQLAlchemy model
- src/fabledassistant/services/images.py — fetch/store/serve logic
- src/fabledassistant/routes/images.py — GET /api/images/<id>
Modified:
- config.py: IMAGE_CACHE_DIR (/data/images), IMAGE_MAX_BYTES (5 MB)
- research.py: _search_searxng_images() — SearXNG categories=images
- tools.py: _IMAGE_TOOLS def + search_images branch in execute_tool
- intent.py: search_images routing rule (explicit visual language only)
- app.py: register images_bp
- docker-compose.yml: image_cache named volume mounted at /data/images
- ToolCallCard.vue: "image_search" label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Raise similarity threshold 0.30 → 0.45: only genuinely relevant notes
shown; loosely-related notes no longer pad the sidebar
- Increase max suggested notes 3 → 8 (zero added compute — threshold is
the real gate; the embedding call is fixed regardless of limit)
- semantic_search_notes now returns list[tuple[float, Note]] instead of
list[Note] so scores propagate through context_meta to the frontend
- Keyword fallback notes carry score=null (no cosine similarity available)
- ChatView sidebar shows % badge on each suggested note:
green ≥75%, amber 60–74%, muted <60%
Hovering reveals the raw score in a tooltip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single POST that classifies natural-language text and creates the
appropriate item (note, task, event, or todo) in one synchronous
request — no SSE, no conversation context needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The same empty-string model guard added to chat.py was missing from the
notes assist route. If default_model was stored as "" in the DB, the
assist route would pass "" to Ollama which responds with 400, surfaced
to the user as a "400" error in the assist panel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The model occasionally passes title as a list (e.g. when asked to create
multiple notes at once), causing asyncpg DataError on the INSERT. Return a
clear error result so the model sees the problem and retries with individual
calls instead of crashing with an unhandled exception.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the paperclip button created a one-shot attachment that was
only included for the single message it was sent with, then discarded.
Now selecting a note via the picker calls includeNote() directly, so it
appears in the "In Context" sidebar and stays for the entire conversation
— consistent with clicking "+" on a suggested note.
Removed attachedNote ref, removeAttachedNote(), the pinned-note sidebar
block, and the contextNoteId/contextNoteTitle sendMessage arguments that
are no longer needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a Python fast-path regex (_PRIOR_WORK_REFS) in classify_intent that
detects phrases like "research you did", "note you made", "using your
research", "based on the research" etc. and returns no-tool immediately —
saving the 19s intent LLM call and correctly letting the main model answer
using search_notes/context rather than firing off a web search.
Also tighten the intent prompt rules for search_web: explicitly prohibit
using it for creative/brainstorming requests or when the user references
existing notes, and add a rule that creative/ideation questions ("think of",
"come up with", "brainstorm") always route to null (chat).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a user selected the 'Default' option in Settings, the dropdown
sent an empty string "" to the backend. The route saved it as a DB row,
which caused get_setting() to return "" instead of falling back to
Config defaults. The chat status endpoint then tried to match "" against
installed model names — always failing — resulting in model: "not_found"
and a permanently failing readiness indicator.
services/settings.py:
- Add delete_setting() helper: removes a setting row so get_setting()
correctly falls back to its hardcoded default argument
routes/settings.py:
- Import delete_setting
- When default_model or intent_model are saved as empty string, delete
the DB row instead of storing "" — cleanly restores Config fallback
routes/chat.py:
- chat_status_route: add explicit `or Config.OLLAMA_MODEL` guard for
any existing "" rows written before this fix (migration safety net)
- send_message and summarize routes: same guard on model resolution
so empty settings never cause silent generation failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
config.py:
- Default OLLAMA_INTENT_MODEL: qwen2.5:1.5b → qwen2.5:7b
- Startup will auto-pull and warm the new model on next container restart
intent.py:
- Replaced phrase-matching examples in search_web and research_topic rules
with semantic descriptions. The 7B model doesn't need example phrases to
understand intent — it can reason from the tool's purpose. Removes implied
usage patterns that caused misclassifications on conversational phrasing
(e.g. "I've been thinking about buying shirts, can you research this?").
- research_topic rule now explicitly covers any subject regardless of phrasing,
including shopping decisions, comparisons, how-things-work questions, etc.
- search_web rule clarified as "short summary, no note" vs research_topic's
"comprehensive written reference"
The 1.5B model required prescriptive phrase examples to route correctly; the
7B model has sufficient language understanding to classify from semantic intent.
Expected improvement: ~1-2s intent calls (vs 0.4-9s for the 1.5B model which
sometimes timed out or misclassified longer/conversational messages).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two fixes for the intent model failing to route 'Research: X' messages
to research_topic:
1. Fast-path in classify_intent: if the message matches ^Research:\s+.+
(the exact format the UI Research button always sends), skip the LLM
call entirely and return research_topic with high confidence. This is
100% reliable and saves an unnecessary model call for this pattern.
2. Expanded research_topic rule examples in the system prompt to include
"Research: X" prefix format, shopping-style queries ("research where
to buy X"), and clarification that the topic is everything after the
keyword — improves LLM routing for natural-language research requests
that don't match the previous narrow examples.
Root cause: qwen2.5:1.5b misclassified "Research: where to buy three-
quarter sleeve tee shirts" as general chat (shopping query phrasing
combined with the colon confused the small model).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the intent model doesn't classify a research request (low confidence,
long message, etc.), the main model (qwen3) would correctly identify
research_topic itself and call it via the streaming tool loop. But
execute_tool("research_topic") only returns a dummy research_pending
placeholder, causing the model to see the result and retry — looping
up to MAX_TOOL_ROUNDS times.
Fix: filter research_topic out of stream_tools (the tool list given to
the main model via stream_chat_with_tools). research_topic is an
intent-only routing tool; the main model should never call it directly.
The full tools list (including research_topic) is still passed to
classify_intent so intent routing continues to work.
The _INTENT_ONLY_TOOLS frozenset makes this pattern explicit and
extensible for future intent-only tools.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
research.py:
- Parallelize all 5 SearXNG queries concurrently (200ms stagger via asyncio.gather)
- Parallelize all URL fetches in parallel (asyncio.gather) — up to 15 URLs at once
instead of sequential fetches; biggest performance win (was O(n) × 15s, now ~15s flat)
- _synthesize_note accepts buf: when provided uses stream_chat (num_ctx=16384,
num_predict=8192) to emit tokens into the chat buffer in real time so users see
the note being written; falls back to generate_completion when buf=None
- Added \n\n---\n\n separator before "Research complete!" to cleanly mark boundary
after streamed synthesis content
intent.py:
- classify_intent passes num_ctx=4096 to generate_completion — reduces VRAM pressure
and prefill time for the intent model call on every single request
generation_task.py:
- _INTENT_TRIGGER_WORDS frozenset (~50 action/object/date words) + _should_skip_intent()
skips intent classification for short messages (≤10 words) with no trigger words;
saves 400-800ms model call for conversational replies ("thanks", "okay", etc.)
- Added \n\n---\n\n separator before research "done" text in research_topic branch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ollama streams message.thinking tokens alongside message.content when
think=True — previously silently dropped. Now forwarded end-to-end.
Backend:
- llm.py: ChatChunk type gains "thinking" variant; stream_chat_with_tools
yields ChatChunk(type="thinking") for msg.thinking chunks before content
- generation_task.py: thinking chunks emit "thinking_chunk" SSE events
(not added to content_so_far — not persisted to DB)
Frontend:
- types/chat.ts: Message.thinking?: string (session-only, not from DB)
- stores/chat.ts: streamingThinking ref; thinking_chunk handler accumulates
chunks; on done, thinking carried into committed Message object then cleared
- ChatMessage.vue: collapsible <details class="thinking-block"> shown for
messages that have .thinking content (collapsed by default)
- ChatView.vue + ChatPanel.vue: live thinking block in streaming bubble —
open while only thinking is flowing, auto-collapses when content arrives;
typing indicator hidden while thinking is active
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of relying solely on retry-on-500, poll /api/ps before starting
any LLM stream so the main model has time to fully load into VRAM.
- llm.py: add wait_for_model_loaded(model, timeout=90s) — polls /api/ps
every 2s, returns True when model appears in loaded list
- generation_task.py: launch model_load_task in parallel with build_context
and classify_intent (both use fast/small-model ops that don't need the
main model); after context is built, await the load task — shows
"Loading model..." status only if the user actually has to wait;
logs a warning and proceeds if 90s timeout elapses
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tags are now a first-class field rather than being auto-extracted from
the note body. A new TagInput.vue chip component handles tag entry in
both editor views with autocomplete, Enter/comma/backspace UX, and
space-to-hyphen sanitization.
Backend:
- routes/notes.py: create reads tags from JSON; update accepts explicit
tags (omit = keep existing); append_tag writes to tags array with
dedup; suggest-tags accepts current_tags filter; remove extract_tags
- routes/tasks.py: same — explicit tags on create/update; remove extract_tags
- services/tag_suggestions.py: current_tags param replaces body extraction
- services/tools.py: create_note tool schema adds tags param; executor passes it
- services/llm.py: system prompt tells LLM to use tags param, not embed #tag in body
Frontend:
- components/TagInput.vue: new chip-based tag input (autocomplete, keyboard UX)
- NoteEditorView.vue / TaskEditorView.vue: tags ref loaded from note.tags;
TagInput placed between title and body; save/autosave include tags; suggest
now adds chips; fetchTagSuggestions passes current_tags; dirty tracks tags
- TiptapEditor.vue: remove fetchTags prop and TagSuggestion extension;
keep TagDecoration for legacy inline #tag highlighting
No DB migration needed — tags column already correct.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tags with spaces (e.g. #science fiction) were breaking extraction because
TAG_RE only matched word characters — it would stop at the space and extract
#science instead of #science-fiction.
- TAG_RE (backend + frontend): add hyphens to character class so #science-fiction
is recognized as a single tag: [\w][\w-]* per segment
- System prompt: instruct LLM to use hyphens in multi-word tags, never spaces
- tag_suggestions.py: update prompt example + sanitize output by replacing
spaces with hyphens as a safety net regardless of LLM output
- append-tag route: sanitize incoming tag (spaces → hyphens) before appending
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds _stream_with_retry() async generator (wraps stream_chat_with_tools
with up to 2 retries on Ollama 500, 3s/6s delay). Previously only the
optimistic round 0 _fill_queue had retry logic. Two paths were still
bare: the declined-write-tool fresh stream, and the round 1+ stream.
Round 1 500s occur when tag suggestions (fire-and-forget inside
execute_tool) race the follow-up stream to the same model. The retry
waits for tag suggestions to complete before succeeding.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ensure_model only downloads a model if missing; it does not load it
into VRAM. The frontend warm call only covers the chat model (and only
after a user opens the dashboard). This left qwen2.5:1.5b (intent) cold,
causing simultaneous cold-load 500s when the first chat arrived.
Now both Config.OLLAMA_MODEL and Config.OLLAMA_INTENT_MODEL are warmed
at startup (after ensuring they're installed) via a fire-and-forget
/api/generate call with keep_alive=30m. The embedding model is still
pulled but not warmed (it's loaded on demand during backfill).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With optimistic streaming, intent (qwen2.5:1.5b) and the main stream
(qwen3:latest) start concurrently. When both models are cold-loading,
Ollama returns 500 for both simultaneously. The intent 500 was already
handled silently in classify_intent; the stream 500 now retries up to
2 times (3s then 6s delay) before propagating as an error. 500s only
occur on the first cold-load pair — subsequent requests hit warm models.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Start the main LLM stream immediately after build_context finishes instead
of waiting for intent classification to complete. Race the two concurrently:
- Intent wins before first token → cancel stream, execute tool (tool path
unchanged: confirmation, acknowledgment, multi-round loop all preserved)
- First token wins → discard intent, user sees output immediately
For pure chat messages (no tool needed, the common case) this eliminates
the full intent classification RTT from TTFT. For tool calls, intent
typically wins the race since it finishes before the main model produces
its first token, so tool behaviour is unchanged in practice.
Also extracts _drain_queue() as a module-level async generator helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Default OLLAMA_INTENT_MODEL to qwen2.5:1.5b in code instead of empty
- Add GET /api/settings/models endpoint returning installed models and defaults
- Validate intent_model against installed models on save (same as default_model)
- Replace intent model text input with a dropdown of installed models
- Add chat model dropdown to Assistant settings section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the hardcoded num_ctx=32768 KV cache allocation with a
configurable env var defaulting to 8192. This significantly reduces
VRAM pressure when multiple services share the GPU.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace pure white backgrounds with off-white indigo-tinted values to
reduce glare, and switch the primary/accent color from Google blue to
the app's brand indigo (#6366f1) for consistency with the header and
email templates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
favicon.svg:
- Light mode: replace near-black fill (#2d3748) with indigo brand color
(#6366f1 fill, #4f46e5 stroke, #a5b4fc page lines) — distinctive and
high-contrast without the dark/black appearance
- Dark mode unchanged
email.py:
- Add _EMAIL_LOGO_SVG: inline SVG with white palette for rendering on
the indigo header (white book, lavender lines, gold sparkle)
- Add _email_html(title, body): shared template wrapper — gray outer
background, white card with border-radius, indigo header with logo +
app name, content area, footer
notifications.py:
- Import and use _email_html for all six email functions: security alert,
password reset, password reset success, invitation, task reminder,
test email
- Clean up all inline HTML to match the new card layout and spacing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without pool_pre_ping, stale pooled connections (left over after a Postgres
restart or network blip) cause immediate query failures. SQLAlchemy then
propagates the error rather than transparently reconnecting, which crashes
the Quart request handler and triggers a Swarm restart loop.
- pool_pre_ping=True: issues a lightweight SELECT 1 before each checkout;
discards and replaces stale connections silently
- pool_recycle=1800: recycles connections every 30 minutes to prevent
long-idle connections from going stale at the TCP/firewall level
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove page title; move chat widget (quick actions + input) to top full-width
- Remove recent chats section beneath the widget
- Inline streaming response stays full-width between widget and grid
- Two-column grid below (3fr tasks / 2fr notes, collapses to 1 col on mobile)
- Left column: all active tasks categorized by urgency — Overdue, Due Today,
Due This Week, High Priority, In Progress, then Other (capped at 10)
- Other section: broad fetch of all non-done tasks deduped against shown
sections; sorted due-dated items first (asc), then undated by priority
- Right column: recent notes bumped from 5 to 8
- Max-width increased from 1200px to 1400px
- Section labels styled as small uppercase headings; overdue/high-priority
retain colored left-border treatment
- Single "See all" link per column header instead of per-section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add IP column to the logs table (hidden on mobile)
- Fix expanded detail row condition: also expands when ip_address is set
even if there is no JSON details blob (login/logout events have ip_address
but null details, so they previously could not be expanded at all)
- Show ip_address as a plain line above the JSON blob in the detail row
- Update colspan from 6 to 7 for the new column
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TRUST_PROXY_HEADERS=true: ensures rate limiting keys on the real client IP
(X-Forwarded-For / X-Real-IP) rather than the Traefik container IP
- SECURE_COOKIES=true: sets the Secure flag on session cookies since the app
is served behind TLS via Traefik
- Remove ports: "5000:5000" — Traefik routes traffic internally; publishing
the port directly would bypass Traefik middleware
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend security & correctness:
- Add rate_limit.py: sliding-window rate limiter (asyncio) applied to login,
register, forgot/reset password endpoints (10/60s or 5/300s per IP)
- app.py: add security headers in after_request (X-Frame-Options, CSP,
X-Content-Type-Options, Referrer-Policy) using setdefault to preserve SSE headers
- auth.py: refactor duplicate login_required/admin_required into shared _check_auth()
- config.py: add TRUST_PROXY_HEADERS for proxy-aware client IP resolution
- routes/auth.py: rate limiting, _client_ip() helper, cleaned-up reset_password route
- routes/chat.py, notes.py, tasks.py: int() DoS fix on last_event_id; limit capped
at 500; date.fromisoformat() wrapped in try/except → 400 on invalid dates
- services/auth.py: fix Setting.user_id update bug (filter on NULL not user.id);
reset_password_with_token returns int|None (user_id) instead of bool
- services/backup.py: add _security_notice to full backup JSON export
- services/assist.py: system prompt explicitly preserves markdown list structure
and nested indented sub-items
Infrastructure:
- docker-compose.yml: add healthcheck on app service (/api/health, 10s interval)
- .dockerignore: prevent secrets/node_modules/__pycache__/.env.* leaking into build
Frontend bug fixes:
- TaskCard.vue, TaskViewerView.vue: fix isOverdue() timezone bug (ISO string compare)
- useAssist.ts: accept() now resets state to idle when document changed since proposal
- stores/chat.ts: fix memory leak in _pollUntilLoaded() (try/catch around fetchStatus)
- TiptapEditor.vue: selection offset uses closest-match strategy (not first-match)
- utils/markdown.ts: explicit DOMPurify config with FORCE_BODY; remove as const
(DOMPurify expects mutable string[])
New features:
- Auto-save (5-minute interval) in NoteEditorView and TaskEditorView — only when
editing an existing dirty record; silent on error, shows "Auto-saved" toast
- sectionParser.ts: top-level bullet/numbered list items are now individual sections
in the AI Assist panel (previously treated as one undifferentiated block)
- editor-shared.css: extracted ~500 lines of CSS duplicated between both editors;
includes .inline-assist-btn at global scope (required for teleported elements)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously .btn-back and .btn-edit in editor/viewer toolbars were plain
primary-colored links while all other toolbar actions were proper buttons.
All four views now use the same bordered button style: subtle border,
secondary text color, hover highlights primary border/text — matching
the existing convert/assist-toggle button aesthetic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>