Remove intent model entirely; quick-capture uses primary model

The separate intent model (OLLAMA_INTENT_MODEL / qwen2.5:7b) is removed
from every part of the system. All classification now uses the primary model.

Changes:
- config.py: remove OLLAMA_INTENT_MODEL
- intent.py: remove classify_intent() and all supporting infrastructure
  (_SYSTEM_PROMPT_TEMPLATE, _RESEARCH_PREFIX, _PRIOR_WORK_REFS); file now
  only contains the quick-capture classifier
- quick_capture.py: classify_capture_intent() now called with Config.OLLAMA_MODEL
- generation_task.py: remove intent_model_setting DB lookup and get_setting import;
  history summarization and research pipeline use the primary model directly
- research.py: remove intent_model parameter from run_research_pipeline() and
  _generate_sub_queries(); both use the model param throughout
- routes/settings.py: remove intent_model from model-key validation and response
- app.py: remove intent model pre-warming at startup
- SettingsView.vue: remove Intent Model selector and related refs/state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 18:41:49 -05:00
parent 53e54ea761
commit 3d7be5888e
8 changed files with 26 additions and 223 deletions
+1 -14
View File
@@ -10,10 +10,8 @@ const authStore = useAuthStore();
const toastStore = useToastStore();
const assistantName = ref("");
const defaultModel = ref("");
const intentModel = ref("");
const installedModels = ref<string[]>([]);
const defaultChatModel = ref("");
const defaultIntentModel = ref("");
const newEmail = ref("");
const emailPassword = ref("");
const changingEmail = ref(false);
@@ -80,10 +78,9 @@ onMounted(async () => {
// Load installed models and configured defaults
try {
const modelsData = await apiGet<{ models: string[]; default_chat_model: string; default_intent_model: string }>("/api/settings/models");
const modelsData = await apiGet<{ models: string[]; default_chat_model: string }>("/api/settings/models");
installedModels.value = modelsData.models;
defaultChatModel.value = modelsData.default_chat_model;
defaultIntentModel.value = modelsData.default_intent_model;
} catch {
// Ollama unreachable — dropdowns will be empty
}
@@ -91,7 +88,6 @@ onMounted(async () => {
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
defaultModel.value = allSettings.default_model ?? "";
intentModel.value = allSettings.intent_model ?? "";
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -190,7 +186,6 @@ async function saveAssistant() {
await store.updateSettings({
assistant_name: assistantName.value.trim() || "Fable",
default_model: defaultModel.value,
intent_model: intentModel.value,
});
saved.value = true;
setTimeout(() => (saved.value = false), 2000);
@@ -414,14 +409,6 @@ function hostname(url: string): string {
</select>
<p class="field-hint">Model used for new conversations.</p>
</div>
<div class="field">
<label for="intent-model">Intent Model</label>
<select id="intent-model" v-model="intentModel" class="input">
<option value="">Default ({{ defaultIntentModel || "qwen2.5:1.5b" }})</option>
<option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
</select>
<p class="field-hint">Smaller/faster model for intent routing before the main model.</p>
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveAssistant" :disabled="saving">
+2 -8
View File
@@ -131,15 +131,9 @@ def create_app() -> Quart:
except Exception:
logger.warning("Failed to warm model '%s'", model, exc_info=True)
# Pull main model and (if configured) a separate intent model concurrently,
# then warm them into VRAM so the first user request doesn't cold-load both
# simultaneously (which causes Ollama 500 races).
# Fire-and-forget so pulls/warming don't block startup.
# Pull and warm the main model into VRAM at startup so the first request is fast.
asyncio.create_task(_pull_model(Config.OLLAMA_MODEL, warm=True))
models_to_warm = {Config.OLLAMA_MODEL}
if Config.OLLAMA_INTENT_MODEL and Config.OLLAMA_INTENT_MODEL != Config.OLLAMA_MODEL:
models_to_warm.add(Config.OLLAMA_INTENT_MODEL)
for _model in models_to_warm:
asyncio.create_task(_pull_model(_model, warm=True))
# Also pull the embedding model (nomic-embed-text by default), but no need to warm it.
if Config.EMBEDDING_MODEL not in models_to_warm:
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
-3
View File
@@ -24,9 +24,6 @@ class Config:
)
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
# Optional dedicated model for intent classification.
# Falls back to OLLAMA_MODEL if not set. Can also be overridden per-user via settings.
OLLAMA_INTENT_MODEL: str = os.environ.get("OLLAMA_INTENT_MODEL", "qwen2.5:7b")
# KV cache context window for generation. Lower = less VRAM, less throughput impact.
OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "16384"))
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
+3 -6
View File
@@ -78,7 +78,7 @@ async def quick_capture_route():
if not text:
return jsonify({"error": "text is required"}), 400
intent_model = Config.OLLAMA_INTENT_MODEL or Config.OLLAMA_MODEL
model = Config.OLLAMA_MODEL
# Build tool list for this user, then restrict to capture-only operations.
all_tools = await get_tools_for_user(uid)
@@ -86,7 +86,7 @@ async def quick_capture_route():
t for t in all_tools if t.get("function", {}).get("name") in _CAPTURE_TOOL_NAMES
]
intent = await classify_capture_intent(text, capture_tools, intent_model)
intent = await classify_capture_intent(text, capture_tools, model)
if intent.should_execute:
# research_topic bypasses execute_tool — run the pipeline directly
@@ -94,9 +94,8 @@ async def quick_capture_route():
from fabledassistant.services.research import run_research_pipeline
topic = intent.arguments.get("topic", text)
model = Config.OLLAMA_MODEL
try:
note = await run_research_pipeline(topic, uid, model, intent_model)
note = await run_research_pipeline(topic, uid, model)
logger.info(
"Quick-capture uid=%d: research note id=%d '%s'",
uid, note.id, note.title,
@@ -114,7 +113,6 @@ async def quick_capture_route():
# 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
@@ -141,7 +139,6 @@ async def quick_capture_route():
# Fallback: classify_capture_intent returned no-tool (e.g. LLM parse failure).
# Still process the text through the note enrichment pass.
model = Config.OLLAMA_MODEL
fallback_title, fallback_body = await _process_note(text, model)
result = await execute_tool(
+7 -9
View File
@@ -25,18 +25,17 @@ async def update_settings_route():
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if "default_model" in data or "intent_model" in data:
if "default_model" in data:
installed = await get_installed_models()
for key in ("default_model", "intent_model"):
if key in data and data[key]:
model = str(data[key])
if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400
if data["default_model"]:
model = str(data["default_model"])
if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400
# Empty string for model keys means "reset to system default".
# Empty string for default_model means "reset to system default".
# Delete the DB row so get_setting() falls back to Config defaults
# rather than returning "" and breaking model resolution everywhere.
_MODEL_KEYS = frozenset({"default_model", "intent_model"})
_MODEL_KEYS = frozenset({"default_model"})
to_save = {}
for k, v in data.items():
str_v = str(v)
@@ -59,7 +58,6 @@ async def get_models_route():
return jsonify({
"models": models,
"default_chat_model": Config.OLLAMA_MODEL,
"default_intent_model": Config.OLLAMA_INTENT_MODEL,
})
@@ -25,7 +25,6 @@ from fabledassistant.services.generation_buffer import (
from fabledassistant.services.llm import ChatChunk, build_context, generate_completion, stream_chat, stream_chat_with_tools, summarize_history_for_context, wait_for_model_loaded
from fabledassistant.services.chat import update_conversation_title
from fabledassistant.services.logging import log_generation
from fabledassistant.services.settings import get_setting
from fabledassistant.services.tools import get_tools_for_user, execute_tool
from fabledassistant.services.research import run_research_pipeline
@@ -156,16 +155,12 @@ async def run_generation(
buf.append_event("status", {"status": "Building context..."})
# Phase 1: Quick DB calls — resolve tools list and intent model in parallel.
tools, intent_model_setting = await asyncio.gather(
get_tools_for_user(user_id),
get_setting(user_id, "intent_model", ""),
)
intent_model = intent_model_setting or Config.OLLAMA_INTENT_MODEL or model
# Phase 1: Resolve the tools list for this user.
tools = await get_tools_for_user(user_id)
logger.info(
"Starting generation for conv %d: model=%s, intent_model=%s, tools=%d",
conv_id, model, intent_model, len(tools),
"Starting generation for conv %d: model=%s, tools=%d",
conv_id, model, len(tools),
)
# Phase 2: Summarize long conversation history if needed.
@@ -173,7 +168,7 @@ async def run_generation(
history_summary: str | None = None
if len(history) > 20: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
buf.append_event("status", {"status": "Summarizing conversation history..."})
history_to_use, history_summary = await summarize_history_for_context(history, intent_model)
history_to_use, history_summary = await summarize_history_for_context(history, model)
# Phase 3: Build context and wait for model in parallel.
model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=90.0))
@@ -259,7 +254,7 @@ async def run_generation(
if tool_name == "research_topic":
topic = arguments.get("topic", "")
try:
note = await run_research_pipeline(topic, user_id, model, intent_model, buf)
note = await run_research_pipeline(topic, user_id, model, buf)
result = {
"success": True,
"type": "research_note",
+3 -167
View File
@@ -1,9 +1,7 @@
"""Intent routing — classify user message before streaming.
"""Quick-capture intent classifier.
Makes a fast non-streaming LLM call to detect tool intent and extract
parameters. When a tool call is detected the caller can execute it
directly, bypassing the model's native (and sometimes unreliable)
structured tool-calling API.
Classifies short capture text (note, task, event, research) for the
/api/quick-capture endpoint using a dedicated prompt and the primary model.
"""
import json
@@ -51,168 +49,6 @@ def _build_tool_summary(tools: list[dict]) -> str:
return "\n".join(lines)
_SYSTEM_PROMPT_TEMPLATE = """\
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}.
Available tools:
{tool_summary}
Respond with ONLY a JSON object, no other text:
- If a tool should be called: {{"tool": "tool_name", "arguments": {{...}}, "confidence": "high"|"medium"|"low", "ack": "One short sentence describing what you're about to do."}}
- If it's general chat: {{"tool": null, "confidence": "high", "ack": null}}
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.
- "mark as done", "complete", "finish", "mark in progress", "start" a task → use update_note with status field.
- "set priority", "change priority", "make it high priority" → use update_note with priority field.
- "set due date", "move due date", "due on Friday" for a task → use update_note with due_date field.
- "what are my overdue tasks", "show overdue", "tasks due today", "high priority tasks", "in progress tasks", "what's due this week" → use list_tasks with appropriate status/priority/due_before/due_after filters. For overdue, set due_before to today's date.
- 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.
- "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.
- "delete", "remove", "trash", "get rid of" a note (not a task) → use delete_note with query=<note name/keyword>. NEVER use this for tasks.
- "delete", "remove", "trash", "get rid of" a task → use delete_task with query=<task name/keyword>. NEVER use this for notes.
- "read", "open", "show me", "what does X say", "display", "pull up" a specific note → use get_note with query=<note name>.
- "list my notes", "show notes", "recent notes", "browse notes", "notes tagged X" → use list_notes (with optional q or tags).
- "tag X with Y", "add tag Y to X", "untag Y from X", "remove tag Y from X" → use update_note with tags=[Y] and tag_mode="add" or "remove".
- search_web: user explicitly wants a quick factual answer from the web — current events, version
numbers, real-time facts ("what is the latest version of X", "who won the game last night").
ONLY use when the information is clearly not in their notes and they need something fresh from
the internet. Do NOT use for creative questions, brainstorming, game design, writing help, or
when the user is building on content they already have. Do NOT use when the user references
their own notes or prior research — use search_notes instead.
- search_images: ONLY when the user explicitly asks to SEE, SHOW, DISPLAY, or VIEW an image or
photo. Trigger phrases: "show me a picture/photo/image of", "what does X look like",
"find a photo of", "display an image of". Do NOT use for general questions about appearance,
descriptions, or when the user just wants information without visual content.
- research_topic: user wants a comprehensive, multi-section research note created from web sources.
Use whenever the user wants to deeply understand, learn about, or get a full written reference
on any subject — regardless of how they phrase it. The topic can be anything: technical subjects,
shopping decisions, comparisons, how things work, historical topics, etc.
The "topic" argument should capture the full subject matter of the request.
Prefer this over search_web when the user's request implies wanting thorough coverage rather than
a quick answer.
- For creative/ideation requests ("think of", "come up with", "ideas for", "help me design",
"imagine", "brainstorm") use null (chat) — the main model answers these directly. Only route
to a tool if the user also explicitly asks to search, look something up, or create/save something.
- If the user references notes or research the assistant previously created, prefer search_notes
(to retrieve the relevant note) or null (chat) so the main model can use its tools to find it.
Never use search_web when existing notes likely contain the answer.
- "ack": one short, natural sentence confirming the action (tool path only). Vary phrasing — do not always start with "Let me". Omit (null) for chat-only responses.
- Do NOT wrap the JSON in markdown code fences."""
# Fast-path: "Research: <topic>" sent by the Research button.
_RESEARCH_PREFIX = re.compile(r"^[Rr]esearch:\s+(.+)", re.DOTALL)
# When the user refers to work the assistant previously did, they want the main
# model to answer using existing context — not a web search. Skip intent and
# fall through to the streaming path so the model can call search_notes itself.
_PRIOR_WORK_REFS = re.compile(
r"\b("
r"research (you|that you|we) did"
r"|research (you|that you|we) (made|created|compiled|wrote)"
r"|note (you|that you|we) (made|created|wrote)"
r"|notes (you|that you|we) (made|created|wrote)"
r"|using (your|the) research"
r"|based on (your|the) research"
r"|from (your|the) (research|note|notes)"
r")\b",
re.IGNORECASE,
)
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.
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:
return IntentResult()
# Fast-path: "Research: <topic>" is the canonical format sent by the Research
# button in the UI. It always means research_topic — skip the LLM call entirely.
valid_names = {t.get("function", {}).get("name") for t in tools}
if "research_topic" in valid_names:
m = _RESEARCH_PREFIX.match(user_message.strip())
if m:
topic = m.group(1).strip()
logger.info("Intent fast-path: 'Research:' prefix → research_topic, topic='%s'", topic[:80])
return IntentResult(
tool_name="research_topic",
arguments={"topic": topic},
confidence="high",
ack=f"I'll research that and compile a comprehensive note.",
)
# Fast-path: user references prior assistant work ("research you did", "note you
# made", etc.) — this is a request to use existing content, not search the web.
# Return no-tool so the main model answers conversationally with search_notes
# available if it needs to retrieve the note.
if _PRIOR_WORK_REFS.search(user_message):
logger.info(
"Intent fast-path: prior-work reference detected → skipping tool dispatch"
)
return IntentResult()
tool_summary = _build_tool_summary(tools)
today = date_type.today().isoformat()
messages: list[dict] = [
{
"role": "system",
"content": _SYSTEM_PROMPT_TEMPLATE.format(
today=today, tool_summary=tool_summary
),
},
]
# 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, max_tokens=350, num_ctx=4096)
except Exception:
logger.warning("Intent classification LLM call failed", exc_info=True)
return IntentResult()
return _parse_intent(raw, tools)
def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
"""Parse the LLM's JSON response into an IntentResult."""
+4 -5
View File
@@ -25,7 +25,6 @@ async def run_research_pipeline(
topic: str,
user_id: int,
model: str,
intent_model: str,
buf=None,
) -> Note:
"""Full research pipeline: search → fetch → synthesize → create note.
@@ -36,7 +35,7 @@ async def run_research_pipeline(
# Step 1: Generate sub-queries
if buf is not None:
buf.append_event("status", {"status": "Generating search queries..."})
queries = await _generate_sub_queries(topic, intent_model)
queries = await _generate_sub_queries(topic, model)
logger.info("Research: generated %d sub-queries for topic '%s'", len(queries), topic)
# Step 2: Search all queries in parallel (200 ms stagger to avoid hammering SearXNG)
@@ -118,8 +117,8 @@ async def run_research_pipeline(
return note
async def _generate_sub_queries(topic: str, intent_model: str) -> list[str]:
"""Ask the intent model for focused search queries for the topic."""
async def _generate_sub_queries(topic: str, model: str) -> list[str]:
"""Ask the model for focused search queries for the topic."""
messages = [
{
"role": "system",
@@ -135,7 +134,7 @@ async def _generate_sub_queries(topic: str, intent_model: str) -> list[str]:
{"role": "user", "content": f"Topic: {topic}"},
]
try:
raw = await generate_completion(messages, intent_model, max_tokens=200)
raw = await generate_completion(messages, model, max_tokens=200)
raw = raw.strip()
raw = re.sub(r"^```(?:json)?\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw)