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:
@@ -10,10 +10,8 @@ const authStore = useAuthStore();
|
|||||||
const toastStore = useToastStore();
|
const toastStore = useToastStore();
|
||||||
const assistantName = ref("");
|
const assistantName = ref("");
|
||||||
const defaultModel = ref("");
|
const defaultModel = ref("");
|
||||||
const intentModel = ref("");
|
|
||||||
const installedModels = ref<string[]>([]);
|
const installedModels = ref<string[]>([]);
|
||||||
const defaultChatModel = ref("");
|
const defaultChatModel = ref("");
|
||||||
const defaultIntentModel = ref("");
|
|
||||||
const newEmail = ref("");
|
const newEmail = ref("");
|
||||||
const emailPassword = ref("");
|
const emailPassword = ref("");
|
||||||
const changingEmail = ref(false);
|
const changingEmail = ref(false);
|
||||||
@@ -80,10 +78,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Load installed models and configured defaults
|
// Load installed models and configured defaults
|
||||||
try {
|
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;
|
installedModels.value = modelsData.models;
|
||||||
defaultChatModel.value = modelsData.default_chat_model;
|
defaultChatModel.value = modelsData.default_chat_model;
|
||||||
defaultIntentModel.value = modelsData.default_intent_model;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ollama unreachable — dropdowns will be empty
|
// Ollama unreachable — dropdowns will be empty
|
||||||
}
|
}
|
||||||
@@ -91,7 +88,6 @@ onMounted(async () => {
|
|||||||
// Load notification preferences from user settings
|
// Load notification preferences from user settings
|
||||||
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
||||||
defaultModel.value = allSettings.default_model ?? "";
|
defaultModel.value = allSettings.default_model ?? "";
|
||||||
intentModel.value = allSettings.intent_model ?? "";
|
|
||||||
if (allSettings.notify_task_reminders !== undefined) {
|
if (allSettings.notify_task_reminders !== undefined) {
|
||||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||||
}
|
}
|
||||||
@@ -190,7 +186,6 @@ async function saveAssistant() {
|
|||||||
await store.updateSettings({
|
await store.updateSettings({
|
||||||
assistant_name: assistantName.value.trim() || "Fable",
|
assistant_name: assistantName.value.trim() || "Fable",
|
||||||
default_model: defaultModel.value,
|
default_model: defaultModel.value,
|
||||||
intent_model: intentModel.value,
|
|
||||||
});
|
});
|
||||||
saved.value = true;
|
saved.value = true;
|
||||||
setTimeout(() => (saved.value = false), 2000);
|
setTimeout(() => (saved.value = false), 2000);
|
||||||
@@ -414,14 +409,6 @@ function hostname(url: string): string {
|
|||||||
</select>
|
</select>
|
||||||
<p class="field-hint">Model used for new conversations.</p>
|
<p class="field-hint">Model used for new conversations.</p>
|
||||||
</div>
|
</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>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-save" @click="saveAssistant" :disabled="saving">
|
<button class="btn-save" @click="saveAssistant" :disabled="saving">
|
||||||
|
|||||||
@@ -131,15 +131,9 @@ def create_app() -> Quart:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to warm model '%s'", model, exc_info=True)
|
logger.warning("Failed to warm model '%s'", model, exc_info=True)
|
||||||
|
|
||||||
# Pull main model and (if configured) a separate intent model concurrently,
|
# Pull and warm the main model into VRAM at startup so the first request is fast.
|
||||||
# then warm them into VRAM so the first user request doesn't cold-load both
|
asyncio.create_task(_pull_model(Config.OLLAMA_MODEL, warm=True))
|
||||||
# simultaneously (which causes Ollama 500 races).
|
|
||||||
# Fire-and-forget so pulls/warming don't block startup.
|
|
||||||
models_to_warm = {Config.OLLAMA_MODEL}
|
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.
|
# 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:
|
if Config.EMBEDDING_MODEL not in models_to_warm:
|
||||||
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
|
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ class Config:
|
|||||||
)
|
)
|
||||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||||
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
|
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.
|
# KV cache context window for generation. Lower = less VRAM, less throughput impact.
|
||||||
OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "16384"))
|
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")
|
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async def quick_capture_route():
|
|||||||
if not text:
|
if not text:
|
||||||
return jsonify({"error": "text is required"}), 400
|
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.
|
# Build tool list for this user, then restrict to capture-only operations.
|
||||||
all_tools = await get_tools_for_user(uid)
|
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
|
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:
|
if intent.should_execute:
|
||||||
# research_topic bypasses execute_tool — run the pipeline directly
|
# 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
|
from fabledassistant.services.research import run_research_pipeline
|
||||||
|
|
||||||
topic = intent.arguments.get("topic", text)
|
topic = intent.arguments.get("topic", text)
|
||||||
model = Config.OLLAMA_MODEL
|
|
||||||
try:
|
try:
|
||||||
note = await run_research_pipeline(topic, uid, model, intent_model)
|
note = await run_research_pipeline(topic, uid, model)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Quick-capture uid=%d: research note id=%d '%s'",
|
"Quick-capture uid=%d: research note id=%d '%s'",
|
||||||
uid, note.id, note.title,
|
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
|
# For notes, run a second LLM pass to generate a proper title and
|
||||||
# well-formed body rather than using the raw capture text verbatim.
|
# well-formed body rather than using the raw capture text verbatim.
|
||||||
if intent.tool_name == "create_note":
|
if intent.tool_name == "create_note":
|
||||||
model = Config.OLLAMA_MODEL
|
|
||||||
title, body = await _process_note(text, model)
|
title, body = await _process_note(text, model)
|
||||||
intent.arguments["title"] = title
|
intent.arguments["title"] = title
|
||||||
intent.arguments["body"] = body
|
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).
|
# Fallback: classify_capture_intent returned no-tool (e.g. LLM parse failure).
|
||||||
# Still process the text through the note enrichment pass.
|
# Still process the text through the note enrichment pass.
|
||||||
model = Config.OLLAMA_MODEL
|
|
||||||
fallback_title, fallback_body = await _process_note(text, model)
|
fallback_title, fallback_body = await _process_note(text, model)
|
||||||
|
|
||||||
result = await execute_tool(
|
result = await execute_tool(
|
||||||
|
|||||||
@@ -25,18 +25,17 @@ async def update_settings_route():
|
|||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return jsonify({"error": "Expected a JSON object"}), 400
|
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()
|
installed = await get_installed_models()
|
||||||
for key in ("default_model", "intent_model"):
|
if data["default_model"]:
|
||||||
if key in data and data[key]:
|
model = str(data["default_model"])
|
||||||
model = str(data[key])
|
if installed and model not in installed:
|
||||||
if installed and model not in installed:
|
return jsonify({"error": f"Model '{model}' is not installed"}), 400
|
||||||
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
|
# Delete the DB row so get_setting() falls back to Config defaults
|
||||||
# rather than returning "" and breaking model resolution everywhere.
|
# rather than returning "" and breaking model resolution everywhere.
|
||||||
_MODEL_KEYS = frozenset({"default_model", "intent_model"})
|
_MODEL_KEYS = frozenset({"default_model"})
|
||||||
to_save = {}
|
to_save = {}
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
str_v = str(v)
|
str_v = str(v)
|
||||||
@@ -59,7 +58,6 @@ async def get_models_route():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
"models": models,
|
"models": models,
|
||||||
"default_chat_model": Config.OLLAMA_MODEL,
|
"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.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.chat import update_conversation_title
|
||||||
from fabledassistant.services.logging import log_generation
|
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.tools import get_tools_for_user, execute_tool
|
||||||
from fabledassistant.services.research import run_research_pipeline
|
from fabledassistant.services.research import run_research_pipeline
|
||||||
|
|
||||||
@@ -156,16 +155,12 @@ async def run_generation(
|
|||||||
|
|
||||||
buf.append_event("status", {"status": "Building context..."})
|
buf.append_event("status", {"status": "Building context..."})
|
||||||
|
|
||||||
# Phase 1: Quick DB calls — resolve tools list and intent model in parallel.
|
# Phase 1: Resolve the tools list for this user.
|
||||||
tools, intent_model_setting = await asyncio.gather(
|
tools = await get_tools_for_user(user_id)
|
||||||
get_tools_for_user(user_id),
|
|
||||||
get_setting(user_id, "intent_model", ""),
|
|
||||||
)
|
|
||||||
intent_model = intent_model_setting or Config.OLLAMA_INTENT_MODEL or model
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Starting generation for conv %d: model=%s, intent_model=%s, tools=%d",
|
"Starting generation for conv %d: model=%s, tools=%d",
|
||||||
conv_id, model, intent_model, len(tools),
|
conv_id, model, len(tools),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 2: Summarize long conversation history if needed.
|
# Phase 2: Summarize long conversation history if needed.
|
||||||
@@ -173,7 +168,7 @@ async def run_generation(
|
|||||||
history_summary: str | None = None
|
history_summary: str | None = None
|
||||||
if len(history) > 20: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
|
if len(history) > 20: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
|
||||||
buf.append_event("status", {"status": "Summarizing conversation history..."})
|
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.
|
# Phase 3: Build context and wait for model in parallel.
|
||||||
model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=90.0))
|
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":
|
if tool_name == "research_topic":
|
||||||
topic = arguments.get("topic", "")
|
topic = arguments.get("topic", "")
|
||||||
try:
|
try:
|
||||||
note = await run_research_pipeline(topic, user_id, model, intent_model, buf)
|
note = await run_research_pipeline(topic, user_id, model, buf)
|
||||||
result = {
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"type": "research_note",
|
"type": "research_note",
|
||||||
|
|||||||
@@ -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
|
Classifies short capture text (note, task, event, research) for the
|
||||||
parameters. When a tool call is detected the caller can execute it
|
/api/quick-capture endpoint using a dedicated prompt and the primary model.
|
||||||
directly, bypassing the model's native (and sometimes unreliable)
|
|
||||||
structured tool-calling API.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -51,168 +49,6 @@ def _build_tool_summary(tools: list[dict]) -> str:
|
|||||||
return "\n".join(lines)
|
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:
|
def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
|
||||||
"""Parse the LLM's JSON response into an IntentResult."""
|
"""Parse the LLM's JSON response into an IntentResult."""
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ async def run_research_pipeline(
|
|||||||
topic: str,
|
topic: str,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
model: str,
|
model: str,
|
||||||
intent_model: str,
|
|
||||||
buf=None,
|
buf=None,
|
||||||
) -> Note:
|
) -> Note:
|
||||||
"""Full research pipeline: search → fetch → synthesize → create note.
|
"""Full research pipeline: search → fetch → synthesize → create note.
|
||||||
@@ -36,7 +35,7 @@ async def run_research_pipeline(
|
|||||||
# Step 1: Generate sub-queries
|
# Step 1: Generate sub-queries
|
||||||
if buf is not None:
|
if buf is not None:
|
||||||
buf.append_event("status", {"status": "Generating search queries..."})
|
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)
|
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)
|
# 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
|
return note
|
||||||
|
|
||||||
|
|
||||||
async def _generate_sub_queries(topic: str, intent_model: str) -> list[str]:
|
async def _generate_sub_queries(topic: str, model: str) -> list[str]:
|
||||||
"""Ask the intent model for focused search queries for the topic."""
|
"""Ask the model for focused search queries for the topic."""
|
||||||
messages = [
|
messages = [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
@@ -135,7 +134,7 @@ async def _generate_sub_queries(topic: str, intent_model: str) -> list[str]:
|
|||||||
{"role": "user", "content": f"Topic: {topic}"},
|
{"role": "user", "content": f"Topic: {topic}"},
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
raw = await generate_completion(messages, intent_model, max_tokens=200)
|
raw = await generate_completion(messages, model, max_tokens=200)
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||||
raw = re.sub(r"\s*```$", "", raw)
|
raw = re.sub(r"\s*```$", "", raw)
|
||||||
|
|||||||
Reference in New Issue
Block a user