Phase 22: SearXNG web research pipeline + settings layout overhaul

Research pipeline (research_topic tool):
- New service: services/research.py — sub-query generation, SearXNG
  search, URL fetch, deduplication, and LLM synthesis into a note
- 5 sub-queries × 3 pages = up to 15 sources, capped at 12 for synthesis
- Synthesis uses num_ctx=16384 + max_tokens=8192 for long-form output
- Prompt demands 2500+ words, 6+ topic-appropriate sections, detailed prose
- 429 retry with backoff; 1s inter-query sleep; raw_decode JSON parsing

search_web tool (new):
- Lightweight single-query SearXNG search, results returned inline in chat
- LLM answers conversationally in round 1; no note created
- web_search result type with external links in ToolCallCard

Infrastructure:
- llm.py: generate_completion accepts num_ctx override
- config.py: SEARXNG_URL + Config.searxng_enabled()
- docker-compose: OLLAMA_NUM_PARALLEL=2, commented SEARXNG_URL example
- intent.py: search_web and research_topic routing rules

Settings UI:
- 2-column grid layout (small sections pair up, complex span full width)
- Search Test section: live SearXNG query with result preview
- GET /api/settings/search?q= proxy endpoint
- Research button (magnifier) in ChatView input toolbar → popover modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 15:21:38 -05:00
parent 432e0bd2a0
commit 590682a5d2
11 changed files with 1132 additions and 413 deletions
@@ -28,6 +28,7 @@ from fabledassistant.services.intent import IntentResult, classify_intent
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
logger = logging.getLogger(__name__)
@@ -59,6 +60,8 @@ _TOOL_LABELS: dict[str, str] = {
"update_todo": "Updating todo",
"complete_todo": "Completing todo",
"delete_todo": "Removing todo",
"search_web": "Searching the web",
"research_topic": "Researching topic",
}
# Tools that write data and require explicit user confirmation before executing.
@@ -285,6 +288,43 @@ async def run_generation(
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
if tool_name == "research_topic":
topic = intent.arguments.get("topic", "")
if not ack_text:
fallback_ack = f"I'll research '{topic}' and compile a note.\n\n"
buf.append_event("chunk", {"chunk": fallback_ack})
buf.content_so_far += fallback_ack
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
try:
note = await run_research_pipeline(
topic, user_id, model, intent_model, buf
)
done_text = (
f"Research complete! I've compiled a note: "
f"**[{note.title}](/notes/{note.id})**."
)
buf.append_event("chunk", {"chunk": done_text})
buf.content_so_far += done_text
tool_record = {
"function": "research_topic",
"arguments": {"topic": topic},
"result": {
"success": True,
"type": "research_note",
"data": {"id": note.id, "title": note.title},
},
"status": "success",
}
all_tool_calls.append(tool_record)
buf.append_event("tool_call", {"tool_call": tool_record})
except Exception as e:
logger.exception("Research pipeline failed for topic: %s", topic)
err_text = f"\nResearch failed: {e}"
buf.append_event("chunk", {"chunk": err_text})
buf.content_so_far += err_text
break # research IS the full response
confirmed = True
if tool_name in _WRITE_TOOLS:
loop = asyncio.get_running_loop()