Phase 22b: Parallel research fetching, streaming synthesis, intent optimizations

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>
This commit is contained in:
2026-02-27 18:24:15 -05:00
parent 590682a5d2
commit df4c52412d
4 changed files with 133 additions and 40 deletions
@@ -97,6 +97,42 @@ _TOOL_ACTIONS: dict[str, str] = {
}
# Words that strongly suggest a tool call is needed.
# If none of these appear in a short message, skip intent classification.
_INTENT_TRIGGER_WORDS: frozenset[str] = frozenset({
# Creation
"create", "add", "make", "new", "write", "set",
# Objects / tools
"note", "notes", "task", "tasks", "event", "calendar", "reminder", "todo",
"meeting", "appointment", "schedule", "due", "deadline",
# Read / search
"find", "search", "look", "show", "list", "get", "read", "open", "fetch",
# Research / web
"research", "investigate", "compile", "report", "google", "web",
# Mutation
"update", "edit", "change", "rename", "move", "reschedule", "delete",
"remove", "cancel", "complete", "finish", "mark", "tag", "untag", "append",
# Dates / times (might trigger calendar tools)
"today", "tomorrow", "yesterday", "next", "last", "week", "month",
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
# Misc triggers
"overdue", "priority", "high", "urgent", "remind", "alert",
})
def _should_skip_intent(message: str) -> bool:
"""Return True if the message is clearly conversational and needs no tool.
Skips intent classification for short messages (≤ 10 words) that contain
none of the trigger words. This saves a model call (~400-800ms) for simple
exchanges like "thanks", "okay", "can you explain that more?", etc.
"""
words = message.lower().split()
if len(words) > 10:
return False
return not any(w in _INTENT_TRIGGER_WORDS for w in words)
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
@@ -227,7 +263,7 @@ async def run_generation(
intent_task: asyncio.Task[IntentResult] | None = None
t_intent = time.monotonic()
if tools:
if tools and not _should_skip_intent(user_content):
intent_history = [
m for m in history_to_use
if m.get("role") in ("user", "assistant") and m.get("content")
@@ -235,6 +271,8 @@ async def run_generation(
intent_task = asyncio.create_task(
classify_intent(user_content, tools, intent_model, history=intent_history)
)
elif tools:
logger.debug("Skipping intent classification for short/conversational message")
messages, context_meta = await context_task
@@ -301,7 +339,7 @@ async def run_generation(
topic, user_id, model, intent_model, buf
)
done_text = (
f"Research complete! I've compiled a note: "
f"\n\n---\n\nResearch complete! I've compiled a note: "
f"**[{note.title}](/notes/{note.id})**."
)
buf.append_event("chunk", {"chunk": done_text})
+1 -1
View File
@@ -150,7 +150,7 @@ async def classify_intent(
messages.append({"role": "user", "content": user_message})
try:
raw = await generate_completion(messages, model, max_tokens=350)
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()
+56 -30
View File
@@ -8,7 +8,7 @@ import re
import httpx
from fabledassistant.config import Config
from fabledassistant.services.llm import fetch_url_content, generate_completion
from fabledassistant.services.llm import fetch_url_content, generate_completion, stream_chat
from fabledassistant.services.notes import create_note
from fabledassistant.models.note import Note
@@ -38,32 +38,45 @@ async def run_research_pipeline(
queries = await _generate_sub_queries(topic, intent_model)
logger.info("Research: generated %d sub-queries for topic '%s'", len(queries), topic)
# Step 2: Search and fetch
all_sources: list[dict] = []
seen_urls: set[str] = set()
for i, query in enumerate(queries):
# Step 2: Search all queries in parallel (200 ms stagger to avoid hammering SearXNG)
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
if i > 0:
await asyncio.sleep(1.0) # avoid hammering SearXNG
await asyncio.sleep(0.2 * i)
buf.append_event("status", {"status": f"Searching: {query}..."})
results = await _search_searxng(query)
logger.info("Research: query '%s'%d results", query, len(results))
return query, results
search_results = await asyncio.gather(
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
)
# Deduplicate URLs across all queries
seen_urls: set[str] = set()
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
for query, results in search_results:
for result in results[:PAGES_PER_QUERY]:
url = result.get("url", "")
if not url or url in seen_urls:
continue
seen_urls.add(url)
title = result.get("title", url)
buf.append_event("status", {"status": f"Reading: {title[:60]}..."})
content = await fetch_url_content(url)
all_sources.append({
"url": url,
"title": title,
"query": query,
"snippet": result.get("snippet", ""),
"content": content,
})
if url and url not in seen_urls:
seen_urls.add(url)
url_tasks.append((url, result, query))
# Fetch all unique URLs in parallel
async def _fetch_source(url: str, result: dict, query: str) -> dict:
title = result.get("title", url)
buf.append_event("status", {"status": f"Reading: {title[:60]}..."})
content = await fetch_url_content(url)
return {
"url": url,
"title": title,
"query": query,
"snippet": result.get("snippet", ""),
"content": content,
}
all_sources: list[dict] = list(await asyncio.gather(
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
))
if not all_sources:
raise ValueError(f"No results found for '{topic}'")
@@ -84,9 +97,9 @@ async def run_research_pipeline(
len(good_sources), len(all_sources), len(synthesis_sources),
)
# Step 4: Synthesize
# Step 4: Synthesize (streams tokens into chat as the note is being written)
buf.append_event("status", {"status": f"Synthesizing report from {len(synthesis_sources)} sources..."})
title, body = await _synthesize_note(topic, synthesis_sources, model)
title, body = await _synthesize_note(topic, synthesis_sources, model, buf)
# Step 5: Create note
buf.append_event("status", {"status": "Saving note..."})
@@ -175,11 +188,13 @@ async def _synthesize_note(
topic: str,
sources: list[dict],
model: str,
buf=None,
) -> tuple[str, str]:
"""Synthesize a comprehensive markdown research document from fetched sources.
Returns (title, body_markdown).
Uses an extended context window so the output can be several thousand words.
When buf is provided, tokens are streamed into the chat buffer in real time
so the user can see the note being written. Uses an extended context window.
"""
sources_text_parts = []
for i, s in enumerate(sources, 1):
@@ -222,13 +237,24 @@ async def _synthesize_note(
},
]
raw = await generate_completion(
messages,
model,
max_tokens=8192,
num_ctx=16384,
)
raw = raw.strip()
if buf is not None:
# Stream tokens into the chat buffer so the user sees the note being written
raw_parts: list[str] = []
async for token in stream_chat(
messages, model, options={"num_ctx": 16384, "num_predict": 8192}
):
raw_parts.append(token)
buf.append_event("chunk", {"chunk": token})
buf.content_so_far += token
raw = "".join(raw_parts).strip()
else:
raw = await generate_completion(
messages,
model,
max_tokens=8192,
num_ctx=16384,
)
raw = raw.strip()
# Extract title from first # heading
lines = raw.splitlines()