feat(research): linked section notes + executive summary in index

Research pipeline now produces an index note with:
- Executive summary (2-3 paragraphs synthesized from sections)
- Clickable links to each section note (/notes/{id})
- Section notes have parent_id pointing to the index

Also improves outline resilience: lowered minimum sections from 3
to 2, retries once on failure before falling back to monolithic note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 12:18:31 -04:00
parent 403eb49de0
commit 1d6905ccc8
2 changed files with 124 additions and 48 deletions
+111 -43
View File
@@ -9,7 +9,7 @@ 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.services.notes import create_note, update_note
from fabledassistant.models.note import Note
logger = logging.getLogger(__name__)
@@ -35,8 +35,8 @@ def _build_sources_block(sources: list[dict]) -> str:
async def _generate_outline(topic: str, sources: list[dict], model: str) -> list[dict]:
"""Generate a topic outline from fetched research sources.
Returns a list of {"title": str, "focus": str} dicts (38 entries).
Returns [] on failure callers must fall back to single-note synthesis.
Returns a list of {"title": str, "focus": str} dicts (28 entries).
Retries once on failure before returning [] (callers fall back to single-note).
"""
import json as _json
@@ -61,23 +61,27 @@ async def _generate_outline(topic: str, sources: list[dict], model: str) -> list
"content": f"Topic: {topic}\n\nSources:\n{sources_block}",
},
]
try:
raw = await generate_completion(messages, model, max_tokens=400)
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):
sections = [
s for s in parsed
if isinstance(s, dict) and s.get("title") and s.get("focus")
]
if len(sections) >= 3:
return sections[:8]
except Exception:
logger.warning("Outline generation failed for topic '%s'", topic, exc_info=True)
for attempt in range(2):
try:
raw = await generate_completion(messages, model, max_tokens=400)
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):
sections = [
s for s in parsed
if isinstance(s, dict) and s.get("title") and s.get("focus")
]
if len(sections) >= 2:
return sections[:8]
except Exception:
logger.warning(
"Outline generation attempt %d failed for topic '%s'",
attempt + 1, topic, exc_info=True,
)
return []
@@ -121,6 +125,49 @@ async def _synthesize_section(
return section_title, raw.strip()
async def _generate_executive_summary(
topic: str,
section_bodies: list[tuple[str, str]],
model: str,
) -> str:
"""Generate a 2-3 paragraph executive summary from completed section notes.
Args:
section_bodies: list of (title, body) pairs from the section notes.
Returns summary markdown (no heading — caller adds structure).
"""
sections_block = "\n\n".join(
f"### {title}\n{body[:1500]}" for title, body in section_bodies
)
messages = [
{
"role": "system",
"content": (
"You are a research summarizer. Given several completed research sections on a topic, "
"write a concise executive summary.\n\n"
"Requirements:\n"
"- 23 paragraphs of substantive prose (150300 words total)\n"
"- Cover the key findings and insights across all sections\n"
"- Highlight the most important or surprising takeaways\n"
"- Write so someone can decide which sections to read in detail\n"
"- Do NOT include headings, bullet points, or source citations\n"
"- Do NOT start with 'This research' or 'This document' — jump straight into the content"
),
},
{
"role": "user",
"content": f"Topic: {topic}\n\nSections:\n{sections_block}",
},
]
try:
raw = await generate_completion(messages, model, max_tokens=600)
return raw.strip()
except Exception:
logger.warning("Executive summary generation failed for '%s'", topic, exc_info=True)
return ""
async def run_research_pipeline(
topic: str,
user_id: int,
@@ -220,28 +267,17 @@ async def run_research_pipeline(
return_exceptions=True,
)
# Step 5: Create section notes sequentially
_status(f"Saving {len(outline)} notes...")
section_note_pairs: list[tuple[dict, Note]] = []
# Collect successful results for index + summary generation
section_results: list[tuple[dict, str, str]] = [] # (outline_entry, title, body)
for section, result in zip(outline, raw_results):
if isinstance(result, Exception):
logger.warning("Section synthesis failed for '%s': %s", section["title"], result)
continue
sec_title, sec_body = result
try:
note = await create_note(
user_id=user_id,
title=sec_title,
body=sec_body,
tags=["research"],
project_id=project_id,
)
section_note_pairs.append((section, note))
except Exception:
logger.warning("Failed to save section note '%s'", sec_title, exc_info=True)
section_results.append((section, sec_title, sec_body))
# All sections failed — fall back to single note
if not section_note_pairs:
if not section_results:
logger.warning("All section syntheses failed, falling back to single note for '%s'", topic)
_status("Synthesizing report (fallback)...")
title, body = await _synthesize_note(topic, synthesis_sources, model, buf=None)
@@ -250,20 +286,24 @@ async def run_research_pipeline(
)
return note
# Step 6: Create index note
# Step 5: Generate executive summary from section content
_status("Writing summary...")
executive_summary = await _generate_executive_summary(
topic, [(t, b) for _, t, b in section_results], model,
)
# Step 6: Create index note first (so section notes can reference it via parent_id)
from datetime import date as _date
index_lines = [
f"Research overview for **{topic}** — {_date.today().isoformat()}",
"",
f"Generated from {len(synthesis_sources)} web sources across {len(section_note_pairs)} sections.",
"",
"## Sections",
f"Generated from {len(synthesis_sources)} web sources across {len(section_results)} sections.",
"",
]
for section, note in section_note_pairs:
index_lines.append(f"- **{note.title}** — {section['focus']}")
index_lines += ["", "*Search for any section title to read it.*"]
if executive_summary:
index_lines += ["## Summary", "", executive_summary, ""]
index_lines += ["## Sections", ""]
# Placeholder — will be updated with real links after section notes are created
index_note = await create_note(
user_id=user_id,
title=f"Research: {topic}",
@@ -271,6 +311,34 @@ async def run_research_pipeline(
tags=["research", "research-index"],
project_id=project_id,
)
# Step 7: Create section notes with parent_id pointing to index
_status(f"Saving {len(section_results)} notes...")
section_note_pairs: list[tuple[dict, Note]] = []
for section, sec_title, sec_body in section_results:
try:
note = await create_note(
user_id=user_id,
title=sec_title,
body=sec_body,
tags=["research"],
project_id=project_id,
parent_id=index_note.id,
)
section_note_pairs.append((section, note))
except Exception:
logger.warning("Failed to save section note '%s'", sec_title, exc_info=True)
# Step 8: Update index note body with real links to section notes
for section, note in section_note_pairs:
index_lines.append(f"- [{note.title}](/notes/{note.id}) — {section['focus']}")
await update_note(
user_id=user_id,
note_id=index_note.id,
body="\n".join(index_lines),
)
logger.info(
"Research: created %d section notes + index id=%d for topic '%s'",
len(section_note_pairs), index_note.id, topic,
+13 -5
View File
@@ -34,8 +34,8 @@ async def test_generate_outline_returns_empty_on_parse_failure():
@pytest.mark.asyncio
async def test_generate_outline_returns_empty_when_fewer_than_3_sections():
"""_generate_outline returns [] when model returns fewer than 3 valid sections."""
async def test_generate_outline_returns_empty_when_fewer_than_2_sections():
"""_generate_outline returns [] when model returns fewer than 2 valid sections."""
from fabledassistant.services.research import _generate_outline
outline_json = json.dumps([{"title": "Only One", "focus": "Focus"}])
@@ -106,7 +106,7 @@ async def test_synthesize_section_strips_whitespace():
@pytest.mark.asyncio
async def test_pipeline_creates_section_notes_and_index():
"""run_research_pipeline creates N section notes + 1 index note, returns index."""
"""run_research_pipeline creates N section notes + 1 index note with links, returns index."""
from unittest.mock import MagicMock
outline = [
@@ -117,23 +117,31 @@ async def test_pipeline_creates_section_notes_and_index():
note_id_counter = iter(range(10, 20))
def _make_note(user_id, title, body, tags, project_id=None):
def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
n = MagicMock()
n.id = next(note_id_counter)
n.title = title
return n
mock_update = AsyncMock()
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline), \
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note):
patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Executive summary text."), \
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock) as mock_update:
from fabledassistant.services.research import run_research_pipeline
result = await run_research_pipeline("test topic", user_id=1, model="test-model")
assert result.title == "Research: test topic"
# Index note body should be updated with section links
mock_update.assert_called_once()
updated_body = mock_update.call_args.kwargs.get("body", "")
assert "/notes/" in updated_body
@pytest.mark.asyncio