8db6b4d230
Runs wiki_search in parallel with SearXNG queries; Wikipedia results (which already carry content via their extract field) are merged into the source pool before outline generation, skipping a separate fetch step. Also fixes a pre-existing F811 ruff violation in the test file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
232 lines
11 KiB
Python
232 lines
11 KiB
Python
"""Tests for the multi-note research pipeline."""
|
|
import json
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_outline_parses_valid_json():
|
|
"""_generate_outline returns parsed sections when model returns valid JSON."""
|
|
from fabledassistant.services.research import _generate_outline
|
|
|
|
outline_json = json.dumps([
|
|
{"title": "Section One", "focus": "Focus one"},
|
|
{"title": "Section Two", "focus": "Focus two"},
|
|
{"title": "Section Three", "focus": "Focus three"},
|
|
])
|
|
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json):
|
|
result = await _generate_outline("test topic", [], "test-model")
|
|
|
|
assert len(result) == 3
|
|
assert result[0]["title"] == "Section One"
|
|
assert result[0]["focus"] == "Focus one"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_outline_returns_empty_on_parse_failure():
|
|
"""_generate_outline returns [] when model output is not parseable JSON."""
|
|
from fabledassistant.services.research import _generate_outline
|
|
|
|
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="not json at all"):
|
|
result = await _generate_outline("test topic", [], "test-model")
|
|
|
|
assert result == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
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"}])
|
|
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json):
|
|
result = await _generate_outline("test topic", [], "test-model")
|
|
|
|
assert result == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_outline_truncates_to_8():
|
|
"""_generate_outline truncates results to at most 8 sections."""
|
|
from fabledassistant.services.research import _generate_outline
|
|
|
|
sections = [{"title": f"Section {i}", "focus": f"Focus {i}"} for i in range(10)]
|
|
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=json.dumps(sections)):
|
|
result = await _generate_outline("test topic", [], "test-model")
|
|
|
|
assert len(result) == 8
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_outline_skips_malformed_entries():
|
|
"""_generate_outline filters out entries missing title or focus."""
|
|
from fabledassistant.services.research import _generate_outline
|
|
|
|
outline_json = json.dumps([
|
|
{"title": "Good One", "focus": "Good focus"},
|
|
{"title": "Missing focus"},
|
|
{"focus": "Missing title"},
|
|
{"title": "Good Two", "focus": "Good focus two"},
|
|
{"title": "Good Three", "focus": "Good focus three"},
|
|
])
|
|
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json):
|
|
result = await _generate_outline("test topic", [], "test-model")
|
|
|
|
assert len(result) == 3
|
|
assert all(s["title"] and s["focus"] for s in result)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_synthesize_section_returns_title_and_body():
|
|
"""_synthesize_section returns (section_title, body) from model output."""
|
|
from fabledassistant.services.research import _synthesize_section
|
|
|
|
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="The body of this section."):
|
|
title, body = await _synthesize_section(
|
|
section_title="Quantum Entanglement: Mechanisms",
|
|
section_focus="How entanglement works at the particle level",
|
|
sources=[],
|
|
model="test-model",
|
|
)
|
|
|
|
assert title == "Quantum Entanglement: Mechanisms"
|
|
assert body == "The body of this section."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_synthesize_section_strips_whitespace():
|
|
"""_synthesize_section strips leading/trailing whitespace from body."""
|
|
from fabledassistant.services.research import _synthesize_section
|
|
|
|
with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="\n\n body text \n\n"):
|
|
title, body = await _synthesize_section("Title", "Focus", [], "model")
|
|
|
|
assert body == "body text"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_creates_section_notes_and_index():
|
|
"""run_research_pipeline creates N section notes + 1 index note with links, returns index."""
|
|
from unittest.mock import MagicMock
|
|
|
|
outline = [
|
|
{"title": "Section A", "focus": "Focus A"},
|
|
{"title": "Section B", "focus": "Focus B"},
|
|
{"title": "Section C", "focus": "Focus C"},
|
|
]
|
|
|
|
note_id_counter = iter(range(10, 20))
|
|
|
|
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
|
|
|
|
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._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
|
|
async def test_pipeline_falls_back_to_single_note_when_outline_empty():
|
|
"""run_research_pipeline falls back to single-note synthesis when _generate_outline returns []."""
|
|
from unittest.mock import MagicMock
|
|
|
|
single_note = MagicMock()
|
|
single_note.id = 99
|
|
single_note.title = "Research: test topic"
|
|
|
|
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=[]), \
|
|
patch("fabledassistant.services.research._synthesize_note", new_callable=AsyncMock, return_value=("Research: test topic", "body")), \
|
|
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, return_value=single_note):
|
|
|
|
from fabledassistant.services.research import run_research_pipeline
|
|
result = await run_research_pipeline("test topic", user_id=1, model="test-model")
|
|
|
|
assert result.id == 99
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_falls_back_when_all_sections_fail():
|
|
"""run_research_pipeline falls back to single note when all section syntheses raise."""
|
|
from unittest.mock import MagicMock
|
|
|
|
outline = [
|
|
{"title": "Section A", "focus": "Focus A"},
|
|
{"title": "Section B", "focus": "Focus B"},
|
|
{"title": "Section C", "focus": "Focus C"},
|
|
]
|
|
fallback_note = MagicMock()
|
|
fallback_note.id = 77
|
|
fallback_note.title = "Research: test topic"
|
|
|
|
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=RuntimeError("synthesis failed")), \
|
|
patch("fabledassistant.services.research._synthesize_note", new_callable=AsyncMock, return_value=("Research: test topic", "body")), \
|
|
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, return_value=fallback_note):
|
|
|
|
from fabledassistant.services.research import run_research_pipeline
|
|
result = await run_research_pipeline("test topic", user_id=1, model="test-model")
|
|
|
|
assert result.id == 77
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_includes_wikipedia_sources():
|
|
"""run_research_pipeline should merge Wikipedia results into the source pool."""
|
|
from unittest.mock import MagicMock
|
|
|
|
wiki_results = [{"title": "Wiki Article", "extract": "Wikipedia content about the topic.", "url": "https://en.wikipedia.org/wiki/Topic"}]
|
|
|
|
outline = [
|
|
{"title": "Section A", "focus": "Focus A"},
|
|
{"title": "Section B", "focus": "Focus B"},
|
|
]
|
|
|
|
note_id_counter = iter(range(30, 40))
|
|
|
|
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
|
|
|
|
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.wiki_search", new_callable=AsyncMock, return_value=wiki_results), \
|
|
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) as mock_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._generate_executive_summary", new_callable=AsyncMock, return_value="Summary."), \
|
|
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
|
|
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock):
|
|
|
|
from fabledassistant.services.research import run_research_pipeline
|
|
await run_research_pipeline("test topic", user_id=1, model="test-model")
|
|
|
|
# The sources passed to _generate_outline should include the Wikipedia article
|
|
sources_arg = mock_outline.call_args[0][1] # second positional arg
|
|
source_urls = [s["url"] for s in sources_arg]
|
|
assert "https://en.wikipedia.org/wiki/Topic" in source_urls
|