810f63e749
run_research_pipeline now accepts project_id; generation_task.py passes workspace_project_id when the tool is called from a workspace context. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
321 lines
13 KiB
Python
321 lines
13 KiB
Python
"""Web research pipeline: sub-queries → SearXNG → fetch → synthesize → note."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
import httpx
|
|
|
|
from fabledassistant.config import Config
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SEARXNG_QUERIES = 5 # sub-queries to generate
|
|
RESULTS_PER_QUERY = 3 # results fetched from SearXNG per query
|
|
PAGES_PER_QUERY = 3 # pages actually read per sub-query (top N results)
|
|
MAX_SYNTHESIS_SOURCES = 12 # deduplicated sources passed to synthesis LLM
|
|
CHARS_PER_SOURCE = 2000 # content chars per source sent to synthesis
|
|
|
|
|
|
async def run_research_pipeline(
|
|
topic: str,
|
|
user_id: int,
|
|
model: str,
|
|
buf=None,
|
|
project_id: int | None = None,
|
|
) -> Note:
|
|
"""Full research pipeline: search → fetch → synthesize → create note.
|
|
|
|
Emits status events via buf.append_event throughout (when buf is provided).
|
|
Returns the created Note.
|
|
"""
|
|
# Step 1: Generate sub-queries
|
|
if buf is not None:
|
|
buf.append_event("status", {"status": "Generating search queries..."})
|
|
queries = await _generate_sub_queries(topic, model)
|
|
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)
|
|
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
|
if i > 0:
|
|
await asyncio.sleep(0.2 * i)
|
|
if buf is not None:
|
|
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 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)
|
|
if buf is not None:
|
|
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}'")
|
|
|
|
# Step 3: Filter failed fetches
|
|
good_sources = [
|
|
s for s in all_sources
|
|
if not s["content"].startswith("[Failed to fetch")
|
|
]
|
|
|
|
if not good_sources:
|
|
raise ValueError(f"Could not read any sources for '{topic}'")
|
|
|
|
# Limit to top N sources for synthesis (already deduplicated by URL)
|
|
synthesis_sources = good_sources[:MAX_SYNTHESIS_SOURCES]
|
|
logger.info(
|
|
"Research: %d/%d sources successfully fetched, using %d for synthesis",
|
|
len(good_sources), len(all_sources), len(synthesis_sources),
|
|
)
|
|
|
|
# Step 4: Synthesize (streams tokens into chat as the note is being written)
|
|
if buf is not None:
|
|
buf.append_event("status", {"status": f"Synthesizing report from {len(synthesis_sources)} sources..."})
|
|
title, body = await _synthesize_note(topic, synthesis_sources, model, buf)
|
|
|
|
# Step 5: Create note
|
|
if buf is not None:
|
|
buf.append_event("status", {"status": "Saving note..."})
|
|
note = await create_note(
|
|
user_id=user_id,
|
|
title=title,
|
|
body=body,
|
|
tags=["research"],
|
|
project_id=project_id,
|
|
)
|
|
logger.info("Research: created note id=%d title='%s'", note.id, note.title)
|
|
return note
|
|
|
|
|
|
async def _generate_sub_queries(topic: str, model: str) -> list[str]:
|
|
"""Ask the model for focused search queries for the topic."""
|
|
messages = [
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
f"You are a research assistant. Given a research topic, generate exactly {SEARXNG_QUERIES} "
|
|
"focused web search queries that together would provide comprehensive coverage of the topic. "
|
|
"Vary the angle of each query: include overview, implementation details, best practices, "
|
|
"common problems, and real-world examples. "
|
|
"Respond with ONLY a JSON array of strings, no other text. "
|
|
'Example: ["query one", "query two", "query three"]'
|
|
),
|
|
},
|
|
{"role": "user", "content": f"Topic: {topic}"},
|
|
]
|
|
try:
|
|
raw = await generate_completion(messages, model, max_tokens=200)
|
|
raw = raw.strip()
|
|
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
|
raw = re.sub(r"\s*```$", "", raw)
|
|
idx = raw.find("[")
|
|
if idx >= 0:
|
|
parsed, _ = json.JSONDecoder().raw_decode(raw[idx:])
|
|
if isinstance(parsed, list) and parsed:
|
|
queries = [str(q).strip() for q in parsed if str(q).strip()]
|
|
if queries:
|
|
return queries[:SEARXNG_QUERIES]
|
|
except Exception:
|
|
logger.warning("Sub-query generation failed, falling back to topic", exc_info=True)
|
|
return [topic]
|
|
|
|
|
|
async def _search_searxng(query: str) -> list[dict]:
|
|
"""Search SearXNG and return top results as [{url, title, snippet}]."""
|
|
url = Config.SEARXNG_URL.rstrip("/") + "/search"
|
|
params = {"q": query, "format": "json", "categories": "general"}
|
|
for attempt in range(3):
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get(url, params=params)
|
|
if resp.status_code == 429:
|
|
retry_after = int(resp.headers.get("Retry-After", "5"))
|
|
wait = min(retry_after, 10) * (attempt + 1)
|
|
logger.warning(
|
|
"SearXNG 429 for query '%s' (attempt %d/3), waiting %ds",
|
|
query, attempt + 1, wait,
|
|
)
|
|
await asyncio.sleep(wait)
|
|
continue
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
results = data.get("results", [])
|
|
out = []
|
|
for r in results[:RESULTS_PER_QUERY]:
|
|
out.append({
|
|
"url": r.get("url", ""),
|
|
"title": r.get("title", ""),
|
|
"snippet": r.get("content", ""),
|
|
})
|
|
return out
|
|
except httpx.HTTPStatusError:
|
|
logger.warning("SearXNG search failed for query '%s'", query, exc_info=True)
|
|
return []
|
|
except Exception:
|
|
logger.warning("SearXNG search failed for query '%s'", query, exc_info=True)
|
|
return []
|
|
logger.warning("SearXNG search gave up after 3 attempts for query '%s'", query)
|
|
return []
|
|
|
|
|
|
async def _search_searxng_images(query: str) -> list[dict]:
|
|
"""Search SearXNG image category and return [{img_src, page_url, title, source_domain}]."""
|
|
url = Config.SEARXNG_URL.rstrip("/") + "/search"
|
|
params = {"q": query, "format": "json", "categories": "images"}
|
|
for attempt in range(3):
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get(url, params=params)
|
|
if resp.status_code == 429:
|
|
retry_after = int(resp.headers.get("Retry-After", "5"))
|
|
wait = min(retry_after, 10) * (attempt + 1)
|
|
logger.warning(
|
|
"SearXNG image 429 for '%s' (attempt %d/3), waiting %ds",
|
|
query, attempt + 1, wait,
|
|
)
|
|
await asyncio.sleep(wait)
|
|
continue
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
out = []
|
|
for r in data.get("results", []):
|
|
img_src = r.get("img_src") or r.get("thumbnail_src", "")
|
|
if not img_src:
|
|
continue
|
|
try:
|
|
from urllib.parse import urlparse
|
|
source_domain = urlparse(r.get("url", "")).netloc or ""
|
|
except Exception:
|
|
source_domain = ""
|
|
out.append({
|
|
"img_src": img_src,
|
|
"page_url": r.get("url", ""),
|
|
"title": r.get("title", ""),
|
|
"source_domain": source_domain,
|
|
})
|
|
return out
|
|
except httpx.HTTPStatusError:
|
|
logger.warning("SearXNG image search failed for '%s'", query, exc_info=True)
|
|
return []
|
|
except Exception:
|
|
logger.warning("SearXNG image search failed for '%s'", query, exc_info=True)
|
|
return []
|
|
logger.warning("SearXNG image search gave up after 3 attempts for '%s'", query)
|
|
return []
|
|
|
|
|
|
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).
|
|
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):
|
|
content = (s.get("content") or s.get("snippet") or "")[:CHARS_PER_SOURCE]
|
|
sources_text_parts.append(
|
|
f"[Source {i}] {s['title']}\nURL: {s['url']}\nSearch query: {s['query']}\n\n{content}"
|
|
)
|
|
sources_block = "\n\n" + ("─" * 60) + "\n\n".join(sources_text_parts)
|
|
|
|
messages = [
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"You are a thorough researcher and writer. "
|
|
"Your task is to write an exhaustive, well-structured document on the given topic — "
|
|
"not a brief summary or intro paragraph.\n\n"
|
|
"Requirements:\n"
|
|
"- Write at least 2500 words of substantive content (excluding the Sources section)\n"
|
|
"- Choose sections (##) that make sense for the topic — let the subject matter determine the structure. "
|
|
"A technical topic might need implementation, configuration, and troubleshooting sections. "
|
|
"A comparison topic might need dedicated sections per subject being compared plus a summary. "
|
|
"A scientific topic might need background, mechanisms, research findings, and implications. "
|
|
"Use your judgment — minimum 6 major sections.\n"
|
|
"- Use ### for subsections where they add clarity\n"
|
|
"- Write in detailed prose paragraphs — do not reduce sections to bullet-point lists\n"
|
|
"- Include specific details, examples, data points, comparisons, and nuance from the sources\n"
|
|
"- Do not pad with vague generalities — every paragraph should say something concrete\n"
|
|
"- The first line must be the document title starting with '# '\n"
|
|
"- End with a '## Sources' section listing every source as a markdown hyperlink\n\n"
|
|
"The reader wants to finish this document with a thorough understanding of the topic, "
|
|
"not just an overview."
|
|
),
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": (
|
|
f"Write a comprehensive reference document on: {topic}\n\n"
|
|
f"Sources ({len(sources)} pages fetched):\n{sources_block}"
|
|
),
|
|
},
|
|
]
|
|
|
|
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()
|
|
title = f"Research: {topic}"
|
|
body_lines = lines
|
|
if lines and lines[0].startswith("# "):
|
|
title = lines[0][2:].strip()
|
|
body_lines = lines[1:]
|
|
|
|
body = "\n".join(body_lines).strip()
|
|
return title, body
|