Reduce perceived latency: move context build into task, title fire-and-forget, think:False on aux calls

- build_context() moved from route handler into run_generation() background task.
  The 202 response now returns immediately; client connects to SSE before
  note search / URL fetch begins, so 'Building context...' status is visible.
- _generate_title() runs in a fire-and-forget asyncio.create_task() after the
  'done' SSE event fires. Users see their response complete 2–5s sooner on new
  conversations; title appears later in the sidebar without blocking the stream.
- generate_completion() now sets think:False and accepts a max_tokens limit.
  Intent classifier passes max_tokens=200 (JSON only), title generator passes
  max_tokens=30 (short title), eliminating qwen3 thinking-mode overhead on these
  auxiliary calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 18:50:37 -05:00
parent 765e99bb24
commit 92bf2768b6
4 changed files with 49 additions and 38 deletions
+4 -8
View File
@@ -23,7 +23,6 @@ from fabledassistant.services.generation_buffer import (
get_buffer,
)
from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.llm import build_context
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
@@ -127,17 +126,14 @@ async def send_message_route(conv_id: int):
if msg.role != "system":
history.append({"role": msg.role, "content": msg.content})
# Build context with note search, URL fetching, etc.
messages, context_meta = await build_context(
uid, history, context_note_id, content, exclude_note_ids=exclude_note_ids
)
model = conv.model or await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
# Launch background generation task
# Launch background generation task (context building happens inside the task)
asyncio.create_task(run_generation(
buf, messages, model, context_meta,
buf, history, model,
uid, conv_id, conv.title, content,
context_note_id=context_note_id,
exclude_note_ids=exclude_note_ids,
))
return jsonify({
+36 -27
View File
@@ -16,7 +16,7 @@ from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Message
from fabledassistant.services.generation_buffer import GenerationBuffer, GenerationState
from fabledassistant.services.llm import ChatChunk, generate_completion, stream_chat, stream_chat_with_tools
from fabledassistant.services.llm import ChatChunk, build_context, generate_completion, stream_chat, stream_chat_with_tools
from fabledassistant.services.chat import update_conversation_title
from fabledassistant.services.intent import classify_intent
from fabledassistant.services.logging import log_generation
@@ -73,7 +73,7 @@ async def _generate_title(messages: list[dict], model: str) -> str:
},
{"role": "user", "content": "\n\n".join(conv_lines)},
]
title = await generate_completion(prompt_messages, model)
title = await generate_completion(prompt_messages, model, max_tokens=30)
title = title.strip().strip('"\'').strip()
return title[:100] if title else ""
@@ -98,18 +98,25 @@ async def _update_message(
async def run_generation(
buf: GenerationBuffer,
messages: list[dict],
history: list[dict],
model: str,
context_meta: dict,
user_id: int,
conv_id: int,
conv_title: str,
user_content: str,
context_note_id: int | None = None,
exclude_note_ids: list[int] | None = None,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
MAX_TOOL_ROUNDS = 5
msg_id = buf.assistant_message_id
# Build context inside the background task so the 202 response returns immediately
buf.append_event("status", {"status": "Building context..."})
messages, context_meta = await build_context(
user_id, history, context_note_id, user_content, exclude_note_ids=exclude_note_ids
)
# Emit context event
buf.append_event("context", {"context": context_meta})
@@ -296,29 +303,6 @@ async def run_generation(
tool_calls=all_tool_calls if all_tool_calls else None,
)
# Count non-system messages to decide on title generation
non_system = [m for m in messages if m["role"] != "system"]
msg_count = len(non_system)
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
if should_gen_title:
# Include the just-generated assistant reply for context
title_messages = messages + [
{"role": "assistant", "content": buf.content_so_far}
]
try:
title = await _generate_title(title_messages, model)
if title:
await update_conversation_title(user_id, conv_id, title)
except Exception:
logger.warning("Failed to generate title for conversation %d", conv_id, exc_info=True)
# Fallback for first message only
if not conv_title:
fallback = user_content[:80]
if len(user_content) > 80:
fallback += "..."
await update_conversation_title(user_id, conv_id, fallback)
timing["total_ms"] = int((time.monotonic() - t_start) * 1000)
logger.info(
"Generation timing for conv %d: total=%dms ttft=%s intent=%s tools=%s generation=%s",
@@ -334,6 +318,31 @@ async def run_generation(
buf.finished_at = time.monotonic()
buf.append_event("done", {"done": True, "message_id": msg_id, "timing": timing})
# Title generation is non-critical — fire-and-forget so done fires immediately
non_system = [m for m in messages if m["role"] != "system"]
msg_count = len(non_system)
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
if should_gen_title:
title_messages = messages + [
{"role": "assistant", "content": buf.content_so_far}
]
async def _bg_title() -> None:
try:
title = await _generate_title(title_messages, model)
if title:
await update_conversation_title(user_id, conv_id, title)
except Exception:
logger.warning("Failed to generate title for conversation %d", conv_id, exc_info=True)
if not conv_title:
fallback = user_content[:80]
if len(user_content) > 80:
fallback += "..."
await update_conversation_title(user_id, conv_id, fallback)
asyncio.create_task(_bg_title())
except Exception as e:
logger.exception("Error in generation task for conversation %d", conv_id)
# Save partial content with error status
+1 -1
View File
@@ -136,7 +136,7 @@ async def classify_intent(
messages.append({"role": "user", "content": user_message})
try:
raw = await generate_completion(messages, model)
raw = await generate_completion(messages, model, max_tokens=200)
except Exception:
logger.warning("Intent classification LLM call failed", exc_info=True)
return IntentResult()
+8 -2
View File
@@ -173,12 +173,18 @@ async def stream_chat_with_tools(
break
async def generate_completion(messages: list[dict], model: str) -> str:
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},
json={
"model": model,
"messages": messages,
"stream": False,
"think": False,
"options": {"num_predict": max_tokens},
},
)
resp.raise_for_status()
data = resp.json()