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:
2026-02-26 22:34:54 -05:00
parent 316a85e13b
commit e119331645
9 changed files with 237 additions and 353 deletions
+2 -2
View File
@@ -113,7 +113,7 @@ async def send_message_route(conv_id: int):
if not content:
return jsonify({"error": "content is required"}), 400
context_note_id = data.get("context_note_id")
exclude_note_ids = data.get("exclude_note_ids") or []
include_note_ids = data.get("include_note_ids") or []
think = bool(data.get("think", False))
# Reject if generation already running for this conversation
@@ -142,7 +142,7 @@ async def send_message_route(conv_id: int):
buf, history, model,
uid, conv_id, conv.title, content,
context_note_id=context_note_id,
exclude_note_ids=exclude_note_ids,
include_note_ids=include_note_ids,
think=think,
))
@@ -75,26 +75,6 @@ class GenerationBuffer:
# Module-level singleton registry
_buffers: dict[int | str, GenerationBuffer] = {}
# Per-conversation note context cache — maps conv_id → sorted list of note IDs.
# Stores the note IDs that were last included in the system prompt so that
# subsequent turns in the same conversation can reuse them, stabilizing the
# system prompt prefix and improving Ollama's KV cache hit rate.
_conv_note_cache: dict[int, list[int]] = {}
def get_conv_note_cache(conv_id: int) -> list[int]:
"""Return cached note IDs for a conversation (empty list if none)."""
return list(_conv_note_cache.get(conv_id, []))
def set_conv_note_cache(conv_id: int, note_ids: list[int]) -> None:
"""Store note IDs to reuse on the next turn of this conversation."""
_conv_note_cache[conv_id] = list(note_ids)
def clear_conv_note_cache(conv_id: int) -> None:
"""Invalidate the note cache for a conversation (e.g. after a note write)."""
_conv_note_cache.pop(conv_id, None)
_cleanup_task: asyncio.Task | None = None
_GRACE_PERIOD = 60.0 # seconds to keep completed buffers
+29 -187
View File
@@ -21,9 +21,6 @@ from fabledassistant.models.conversation import Message
from fabledassistant.services.generation_buffer import (
GenerationBuffer,
GenerationState,
clear_conv_note_cache,
get_conv_note_cache,
set_conv_note_cache,
)
from fabledassistant.services.llm import ChatChunk, build_context, generate_completion, stream_chat, stream_chat_with_tools, summarize_history_for_context
from fabledassistant.services.chat import update_conversation_title
@@ -97,39 +94,6 @@ _TOOL_ACTIONS: dict[str, str] = {
}
async def _generate_acknowledgment(user_content: str, tool_name: str, model: str) -> str:
"""Generate a brief conversational acknowledgment that runs in parallel with tool execution.
Uses the intent model (small, fast, already in VRAM) so the sentence is ready
within ~200-400ms. Returned string includes a trailing double-newline so the
main LLM response starts on a new paragraph.
"""
action = _TOOL_ACTIONS.get(tool_name, "work on that")
messages = [
{
"role": "system",
"content": (
"You are a helpful assistant. Write ONE short, natural sentence acknowledging "
"what you are about to do. Vary your phrasing — do not always start with "
"'Let me'. Be warm and conversational. Do not answer the question yet. "
"Output only the sentence, nothing else."
),
},
{
"role": "user",
"content": f"User said: {user_content}\nYou are about to: {action}",
},
]
try:
ack = await generate_completion(messages, model, max_tokens=40)
ack = ack.strip()
if ack:
return ack + "\n\n"
except Exception:
logger.warning("Failed to generate acknowledgment", exc_info=True)
return ""
async def _generate_title(messages: list[dict], model: str) -> str:
"""Ask the LLM for a concise conversation title."""
# Build conversation text like summarize_conversation_as_note
@@ -175,26 +139,6 @@ async def _update_message(
await session.commit()
async def _drain_queue(
prefetched: list[ChatChunk],
queue: asyncio.Queue,
) -> AsyncGenerator[ChatChunk, None]:
"""Yield pre-fetched chunks then drain remaining chunks from the queue.
A None sentinel in the queue signals the stream is finished.
A BaseException in the queue is re-raised so callers see the error.
"""
for chunk in prefetched:
yield chunk
while True:
item = await queue.get()
if item is None:
break
if isinstance(item, BaseException):
raise item
yield item
async def _stream_with_retry(
messages: list[dict],
model: str,
@@ -239,7 +183,7 @@ async def run_generation(
conv_title: str,
user_content: str,
context_note_id: int | None = None,
exclude_note_ids: list[int] | None = None,
include_note_ids: list[int] | None = None,
think: bool = False,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
@@ -266,17 +210,13 @@ async def run_generation(
buf.append_event("status", {"status": "Summarizing conversation history..."})
history_to_use, history_summary = await summarize_history_for_context(history, intent_model)
# Phase 3: Build context. Start intent classification concurrently when tools
# are available so it runs in parallel with the embedding/DB work in build_context.
# We only block on context (need messages to stream) — intent result is consumed
# later via a race with the first streaming token.
cached_note_ids = get_conv_note_cache(conv_id) or None
# Phase 3: Build context and start intent classification in parallel.
# We block on context (need messages to stream) — intent is consumed
# after context is ready, at the start of round 0.
context_task = asyncio.create_task(build_context(
user_id, history_to_use, context_note_id, user_content,
exclude_note_ids=exclude_note_ids,
history_summary=history_summary,
cached_note_ids=cached_note_ids,
include_note_ids=include_note_ids,
))
intent_task: asyncio.Task[IntentResult] | None = None
@@ -292,11 +232,6 @@ async def run_generation(
messages, context_meta = await context_task
# Update the note cache with whatever notes ended up in context.
new_note_ids = context_meta.get("auto_note_ids") or []
if new_note_ids:
set_conv_note_cache(conv_id, new_note_ids)
# Emit context event
buf.append_event("context", {"context": context_meta})
@@ -319,89 +254,27 @@ async def run_generation(
round_tool_calls: list[dict] = []
logger.info("Generation round %d started for conv %d (model=%s)", _round, conv_id, model)
# --- Round 0 with tools: optimistic streaming ---
# Start the main stream immediately (into a queue) while the intent
# classifier finishes in the background. Race the two:
# • Intent wins before first token → check for tool call, cancel stream if needed
# • First token wins → discard intent, stream has already started
# --- Round 0 with tools: intent-first pipeline ---
# Wait for intent result, then act immediately. The ack sentence
# (embedded in the intent JSON) is streamed at TTFT (~400ms), then
# the tool runs while the user is reading it.
if _round == 0 and tools and intent_task is not None:
stream_queue: asyncio.Queue[ChatChunk | BaseException | None] = asyncio.Queue(maxsize=256)
intent = await intent_task
timing["intent_ms"] = int((time.monotonic() - t_intent) * 1000)
async def _fill_queue() -> None:
# Retry on Ollama 500 (model cold-loading race) up to 2 times.
last_exc: BaseException | None = None
for attempt in range(3):
if attempt > 0:
wait = 3.0 * attempt
logger.warning(
"Ollama stream 500 (attempt %d/3), retrying in %.0fs", attempt, wait
)
await asyncio.sleep(wait)
try:
async for c in stream_chat_with_tools(messages, model, tools=tools, think=think):
await stream_queue.put(c)
last_exc = None
break
except httpx.HTTPStatusError as exc:
last_exc = exc
if exc.response.status_code != 500:
break # non-500 errors are not retryable
except BaseException as exc:
last_exc = exc
break
if last_exc is not None:
await stream_queue.put(last_exc)
await stream_queue.put(None)
stream_fill_task = asyncio.create_task(_fill_queue())
buf.append_event("status", {"status": "Generating response..."})
queue_peek = asyncio.create_task(stream_queue.get())
race_done, _ = await asyncio.wait(
{intent_task, queue_peek},
return_when=asyncio.FIRST_COMPLETED,
)
intent = IntentResult()
prefetched: list[ChatChunk] = []
use_tool_path = False
if intent_task in race_done and queue_peek not in race_done:
# Intent finished before the first streaming token arrived.
timing["intent_ms"] = int((time.monotonic() - t_intent) * 1000)
intent = intent_task.result()
if intent.should_execute:
# Cancel the optimistic stream — we'll execute a tool instead.
stream_fill_task.cancel()
queue_peek.cancel()
use_tool_path = True
else:
# No tool needed — collect the first chunk and keep streaming.
first = await queue_peek
if isinstance(first, BaseException):
raise first
if first is not None:
prefetched.append(first)
else:
# Stream produced a token before intent finished (or simultaneously).
# Discard intent — the response is already on its way.
if not intent_task.done():
intent_task.cancel()
else:
timing["intent_ms"] = int((time.monotonic() - t_intent) * 1000)
first = queue_peek.result() if queue_peek in race_done else await queue_peek
if isinstance(first, BaseException):
raise first
if first is not None:
prefetched.append(first)
if use_tool_path:
# === Tool path (intent won the race) ===
if intent.should_execute:
tool_name = intent.tool_name
confirmed = True
# Stream ack immediately — this becomes TTFT
ack_text = (intent.ack or "").strip()
if ack_text:
ack_with_newline = ack_text + "\n\n"
buf.append_event("chunk", {"chunk": ack_with_newline})
buf.content_so_far += ack_with_newline
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
confirmed = True
if tool_name in _WRITE_TOOLS:
loop = asyncio.get_running_loop()
confirm_future: asyncio.Future = loop.create_future()
@@ -449,24 +322,11 @@ async def run_generation(
if confirmed:
buf.append_event("status", {"status": f"{_TOOL_LABELS.get(tool_name, 'Working')}..."})
t_tool = time.monotonic()
result, ack_text = await asyncio.gather(
execute_tool(user_id, tool_name, intent.arguments),
_generate_acknowledgment(user_content, tool_name, intent_model),
)
result = await execute_tool(user_id, tool_name, intent.arguments)
timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Intent-routed tool %s result: success=%s", tool_name, result.get("success"))
if ack_text:
buf.append_event("chunk", {"chunk": ack_text})
buf.content_so_far += ack_text
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
if result.get("success") and tool_name in {"create_task", "create_note", "update_note", "delete_note", "delete_task"}:
clear_conv_note_cache(conv_id)
tool_record = {
"function": tool_name,
"arguments": intent.arguments,
@@ -478,35 +338,23 @@ async def run_generation(
messages.append({
"role": "assistant",
"content": ack_text,
"content": buf.content_so_far,
"tool_calls": [
{"function": {"name": tool_name, "arguments": intent.arguments}}
],
})
messages.append({
"role": "tool",
"content": json.dumps(result),
})
continue # Round 1: stream the response incorporating tool result
messages.append({"role": "tool", "content": json.dumps(result)})
continue # Round 1: stream response with tool result
# Declined write tool — fall through to stream a fresh response.
# Declined write tool — fall through to fresh stream.
if cancelled:
break
# === Stream path for round 0 ===
# Either intent said no-tool, or a write tool was declined.
# For no-tool: drain the already-started queue (prefetched + remaining).
# For declined: start a fresh stream (queue was cancelled).
# No tool (or declined write tool): stream directly, no queue.
buf.append_event("status", {"status": "Generating response..."})
t_stream = time.monotonic()
if use_tool_path:
# Declined write tool — the optimistic stream was cancelled, start fresh.
stream_source: AsyncGenerator = _stream_with_retry(messages, model, tools, think)
else:
stream_source = _drain_queue(prefetched, stream_queue)
async for chunk in stream_source:
async for chunk in _stream_with_retry(messages, model, tools, think):
if buf.cancel_event.is_set():
cancelled = True
break
@@ -541,9 +389,6 @@ async def run_generation(
timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Tool %s result: success=%s", tool_name, result.get("success"))
if result.get("success") and tool_name in {"create_task", "create_note", "update_note", "delete_note", "delete_task"}:
clear_conv_note_cache(conv_id)
tool_record = {
"function": tool_name,
"arguments": arguments,
@@ -618,9 +463,6 @@ async def run_generation(
timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Tool %s result: success=%s", tool_name, result.get("success"))
if result.get("success") and tool_name in {"create_task", "create_note", "update_note", "delete_note", "delete_task"}:
clear_conv_note_cache(conv_id)
tool_record = {
"function": tool_name,
"arguments": arguments,
+10 -4
View File
@@ -22,6 +22,7 @@ class IntentResult:
tool_name: str | None = None # None = no tool, just chat
arguments: dict = field(default_factory=dict)
confidence: str = "high" # "high", "medium", or "low"
ack: str | None = None # One-sentence acknowledgment to stream immediately
@property
def should_execute(self) -> bool:
@@ -61,8 +62,8 @@ 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"}}
- If it's general chat: {{"tool": null, "confidence": "high"}}
- 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
@@ -99,6 +100,7 @@ Rules:
- "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".
- "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."""
@@ -142,7 +144,7 @@ async def classify_intent(
messages.append({"role": "user", "content": user_message})
try:
raw = await generate_completion(messages, model, max_tokens=200)
raw = await generate_completion(messages, model, max_tokens=350)
except Exception:
logger.warning("Intent classification LLM call failed", exc_info=True)
return IntentResult()
@@ -192,11 +194,15 @@ def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
if not isinstance(arguments, dict):
arguments = {}
ack = parsed.get("ack") or None
if ack is not None:
ack = ack.strip() or None
logger.info(
"Intent classified: tool=%s, confidence=%s, args=%s",
tool_name, confidence, json.dumps(arguments)[:200],
)
return IntentResult(tool_name=tool_name, arguments=arguments, confidence=confidence)
return IntentResult(tool_name=tool_name, arguments=arguments, confidence=confidence, ack=ack)
def _try_json(text: str) -> dict | list | None:
+79 -64
View File
@@ -1,3 +1,4 @@
import asyncio
import json
import logging
import re
@@ -178,21 +179,41 @@ async def stream_chat_with_tools(
async def generate_completion(messages: list[dict], model: str, max_tokens: int = 4096) -> str:
"""Non-streaming chat completion, returns full response text."""
async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=30.0, read=300.0)) as client:
resp = await client.post(
f"{Config.OLLAMA_URL}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"think": False,
"options": {"num_predict": max_tokens},
},
)
resp.raise_for_status()
data = resp.json()
return data.get("message", {}).get("content", "")
"""Non-streaming chat completion, returns full response text.
Retries up to 2 times on Ollama 500 errors (cold model loading race).
"""
last_exc: Exception | None = None
for attempt in range(3):
if attempt > 0:
delay = 3.0 * attempt
logger.warning(
"generate_completion 500 (attempt %d/3), retrying in %.0fs", attempt, delay
)
await asyncio.sleep(delay)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=30.0, read=300.0)) as client:
resp = await client.post(
f"{Config.OLLAMA_URL}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"think": False,
"options": {"num_predict": max_tokens},
},
)
resp.raise_for_status()
data = resp.json()
return data.get("message", {}).get("content", "")
except httpx.HTTPStatusError as exc:
last_exc = exc
if exc.response.status_code != 500:
break
except Exception as exc:
last_exc = exc
break
raise last_exc
async def fetch_url_content(url: str) -> str:
@@ -307,7 +328,7 @@ async def build_context(
user_message: str,
exclude_note_ids: list[int] | None = None,
history_summary: str | None = None,
cached_note_ids: list[int] | None = None,
include_note_ids: list[int] | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
@@ -376,64 +397,58 @@ async def build_context(
f"--- End Note ---"
)
# Find related notes to inject into context.
# Priority: (1) use cached note IDs from a previous turn in this conversation
# (2) try semantic search via nomic-embed-text
# (3) fall back to keyword search
# The cache stabilises the system prompt prefix so Ollama's KV cache stays warm.
# Search for related notes to populate sidebar candidates.
# Results are NOT injected into the system prompt automatically — this keeps
# the system prompt prefix stable so Ollama's KV cache can reuse prefill state.
# Users can explicitly include notes via the sidebar (include_note_ids).
search_exclude = set(exclude_set)
if current_note_id:
search_exclude.add(current_note_id)
found_notes = []
if cached_note_ids:
# Load the same notes as last turn — keeps system prompt prefix identical.
try:
from fabledassistant.services.notes import get_note as _get_note
for nid in cached_note_ids:
if nid not in search_exclude:
n = await _get_note(user_id, nid)
if n:
found_notes.append(n)
except Exception:
logger.warning("Failed to load cached notes for context", exc_info=True)
found_notes = []
# Try semantic search first; fall back to keyword search on failure / no results.
try:
from fabledassistant.services.embeddings import semantic_search_notes
found_notes = await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=3
)
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
if not found_notes:
# Try semantic search first; fall back to keyword search on failure / no results.
try:
from fabledassistant.services.embeddings import semantic_search_notes
found_notes = await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=3
)
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
keywords = _extract_keywords(user_message)
if keywords:
try:
found_notes = await search_notes_for_context(
user_id, keywords, exclude_ids=search_exclude or None, limit=3
)
except Exception:
logger.warning("Failed to search notes for context", exc_info=True)
if not found_notes:
keywords = _extract_keywords(user_message)
if keywords:
try:
found_notes = await search_notes_for_context(
user_id, keywords, exclude_ids=search_exclude or None, limit=3
)
except Exception:
logger.warning("Failed to search notes for context", exc_info=True)
if found_notes:
snippets: list[str] = []
for n in found_notes:
body_preview = n.body[:2000] if n.body else ""
snippets.append(f"- {n.title}: {body_preview}")
context_meta["auto_notes"].append({"id": n.id, "title": n.title})
system_parts.append(
"\n\n--- Related Notes ---\n"
+ "\n".join(snippets)
+ "\n--- End Related Notes ---"
)
# Expose note IDs so the caller can update the per-conversation cache.
# Populate sidebar candidates (never auto-injected).
for n in found_notes:
context_meta["auto_notes"].append({"id": n.id, "title": n.title})
context_meta["auto_note_ids"] = [n.id for n in found_notes]
# Inject explicitly included notes (user opted in via sidebar click).
if include_note_ids:
from fabledassistant.services.notes import get_note as _get_note
included_snippets: list[str] = []
for nid in include_note_ids:
try:
n = await _get_note(user_id, nid)
if n:
body_preview = n.body[:2000] if n.body else ""
included_snippets.append(f"- {n.title}: {body_preview}")
except Exception:
logger.warning("Failed to load included note %d for context", nid, exc_info=True)
if included_snippets:
system_parts.append(
"\n\n--- Included Notes ---\n"
+ "\n".join(included_snippets)
+ "\n--- End Included Notes ---"
)
# Fetch URL content from user message
urls = _find_urls(user_message)
for url in urls[:2]: # Limit to 2 URLs