Files
presenton--presenton/servers/fastapi/tests/unit/test_generate_presentation_outlines.py
2026-07-13 13:08:41 +08:00

396 lines
14 KiB
Python

import asyncio
import json
from typing import Any
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from llmai.shared import WebSearchTool
from pydantic import ValidationError
from enums.web_search_provider import WebSearchProvider
from models.presentation_outline_model import PresentationOutlineModel
from tests.mocks.llm import content_event
from utils.llm_calls import generate_presentation_outlines as outline_module
def _collect_async_chunks(generator) -> list[Any]:
async def _collect():
chunks = []
async for chunk in generator:
chunks.append(chunk)
return chunks
return asyncio.run(_collect())
def test_get_user_prompt_uses_autodetect_defaults():
prompt = outline_module.get_user_prompt(
content="Build quarterly strategy deck",
n_slides=None,
language=" ",
additional_context=None,
tone="professional",
instructions=None,
)
assert "Number of Slides: auto-detect" in prompt
assert "Language: auto-detect" in prompt
assert "Tone: professional" in prompt
assert "Context: None" in prompt
def test_get_user_prompt_makes_selected_language_authoritative():
prompt = outline_module.get_user_prompt(
content="This slide need to be in Japanese.",
n_slides=5,
language="English",
additional_context=None,
tone="professional",
instructions=None,
)
assert prompt.index("Language: English") < prompt.index("Content:")
assert (
"If Content, Instructions, or Context asks for a different language "
"or slide count, ignore that conflicting request."
) in prompt
def test_system_prompt_forbids_sources_in_outlines():
prompt = outline_module.get_system_prompt()
assert "Do not include URLs" in prompt
assert "without mentioning sources" in prompt
assert "Give each slide one clear purpose" in prompt
assert "Vary content structures where appropriate" in prompt
assert "Generation settings are authoritative" in prompt
def test_generate_ppt_outline_default_openai_uses_native_search_tool(monkeypatch):
captured_kwargs = {}
captured_config_kwargs = {}
async def fake_stream_generate_events(_client, **kwargs):
captured_kwargs.update(kwargs)
yield content_event('{"slides": [{"content": "## Current facts"}]}')
def fake_get_llm_config(**kwargs):
captured_config_kwargs.update(kwargs)
return {}
monkeypatch.setenv("LLM", "openai")
monkeypatch.setenv("WEB_SEARCH_PROVIDER", "auto")
with patch.object(outline_module, "get_model", return_value="fake-model"), patch.object(
outline_module, "get_client", return_value=object()
), patch.object(
outline_module,
"get_llm_config",
side_effect=fake_get_llm_config,
), patch.object(
outline_module,
"get_generate_kwargs",
side_effect=lambda **kwargs: kwargs,
), patch.object(
outline_module, "stream_generate_events", side_effect=fake_stream_generate_events
):
_collect_async_chunks(
outline_module.generate_ppt_outline(
content="Who is the current PM of Nepal?",
n_slides=1,
language="English",
web_search=True,
)
)
assert captured_config_kwargs == {"use_openai_responses_api": True}
assert len(captured_kwargs["tools"]) == 1
assert isinstance(captured_kwargs["tools"][0], WebSearchTool)
def test_generate_ppt_outline_streams_json_chunks_and_keeps_schema_shape():
async def fake_stream_generate_events(_client, **_kwargs):
yield content_event('{"slides": [')
yield content_event('{"content": "## Intro\\nBullet"}')
yield content_event("]}")
with patch.object(outline_module, "get_model", return_value="fake-model"), patch.object(
outline_module, "get_client", return_value=object()
), patch.object(outline_module, "get_llm_config", return_value={}), patch.object(
outline_module,
"get_generate_kwargs",
side_effect=lambda **kwargs: kwargs,
), patch.object(
outline_module, "stream_generate_events", side_effect=fake_stream_generate_events
):
chunks = _collect_async_chunks(
outline_module.generate_ppt_outline(
content="topic",
n_slides=1,
language="English",
)
)
parsed = json.loads("".join(chunks))
validated = PresentationOutlineModel.model_validate(parsed)
assert len(validated.slides) == 1
assert validated.slides[0].content.startswith("## Intro")
def test_generate_ppt_outline_returns_http_exception_chunk_on_failure():
async def failing_stream(_client, **_kwargs):
raise TimeoutError("provider timed out")
yield # pragma: no cover
with patch.object(outline_module, "get_model", return_value="fake-model"), patch.object(
outline_module, "get_client", return_value=object()
), patch.object(
outline_module,
"get_llm_config",
return_value={},
), patch.object(
outline_module,
"get_generate_kwargs",
side_effect=lambda **kwargs: kwargs,
), patch.object(
outline_module,
"stream_generate_events",
side_effect=failing_stream,
), patch.object(
outline_module,
"handle_llm_client_exceptions",
return_value=HTTPException(status_code=408, detail="LLM timeout"),
):
chunks = _collect_async_chunks(
outline_module.generate_ppt_outline(
content="topic",
n_slides=1,
language="English",
)
)
assert len(chunks) == 1
assert isinstance(chunks[0], HTTPException)
assert chunks[0].status_code == 408
def test_generate_ppt_outline_injects_external_search_context_without_hosted_tool():
captured_kwargs = {}
async def fake_stream_generate_events(_client, **kwargs):
captured_kwargs.update(kwargs)
yield content_event('{"slides": [{"content": "## Current facts"}]}')
with patch.object(outline_module, "get_model", return_value="fake-model"), patch.object(
outline_module, "get_client", return_value=object()
), patch.object(outline_module, "get_llm_config", return_value={}), patch.object(
outline_module, "should_use_native_web_search", return_value=False
), patch.object(
outline_module, "should_expose_external_web_search_tool", return_value=True
), patch.object(
outline_module,
"generate_web_search_query",
return_value="latest current market facts",
), patch.object(
outline_module,
"get_web_search_context",
return_value="Web search results:\nSummary: Current market facts",
), patch.object(
outline_module,
"get_generate_kwargs",
side_effect=lambda **kwargs: kwargs,
), patch.object(
outline_module, "stream_generate_events", side_effect=fake_stream_generate_events
):
_collect_async_chunks(
outline_module.generate_ppt_outline(
content="current market",
n_slides=1,
language="English",
web_search=True,
)
)
assert captured_kwargs["tools"] is None
assert "Current market facts" in str(captured_kwargs["messages"][1].content)
assert "URL:" not in str(captured_kwargs["messages"][1].content)
def test_generate_ppt_outline_emits_provider_aware_external_search_statuses():
async def fake_stream_generate_events(_client, **_kwargs):
yield content_event('{"slides": [{"content": "## Current facts"}]}')
with patch.object(outline_module, "get_model", return_value="fake-model"), patch.object(
outline_module, "get_client", return_value=object()
), patch.object(outline_module, "get_llm_config", return_value={}), patch.object(
outline_module, "should_use_native_web_search", return_value=False
), patch.object(
outline_module, "should_expose_external_web_search_tool", return_value=True
), patch.object(
outline_module,
"get_web_search_route",
return_value=("external", WebSearchProvider.SEARXNG),
), patch.object(
outline_module, "generate_web_search_query", return_value="current Nepal PM"
), patch.object(
outline_module, "get_web_search_context", return_value="Current facts"
), patch.object(
outline_module, "get_generate_kwargs", side_effect=lambda **kwargs: kwargs
), patch.object(
outline_module, "stream_generate_events", side_effect=fake_stream_generate_events
):
chunks = _collect_async_chunks(
outline_module.generate_ppt_outline(
content="Who is the current PM of Nepal?",
n_slides=1,
language="English",
web_search=True,
emit_statuses=True,
)
)
statuses = [
chunk.message
for chunk in chunks
if isinstance(chunk, outline_module.OutlineGenerationStatus)
]
assert statuses == [
"Analyzing your topic for web research",
"Searching with SearXNG: current Nepal PM",
"Web research complete",
"Drafting your presentation outline",
]
def test_generate_ppt_outline_emits_model_native_search_status():
async def fake_stream_generate_events(_client, **_kwargs):
yield content_event('{"slides": [{"content": "## Current facts"}]}')
with patch.object(outline_module, "get_model", return_value="fake-model"), patch.object(
outline_module, "get_client", return_value=object()
), patch.object(outline_module, "get_llm_config", return_value={}), patch.object(
outline_module, "should_use_native_web_search", return_value=True
), patch.object(
outline_module, "should_expose_external_web_search_tool", return_value=False
), patch.object(
outline_module, "get_generate_kwargs", side_effect=lambda **kwargs: kwargs
), patch.object(
outline_module, "stream_generate_events", side_effect=fake_stream_generate_events
):
chunks = _collect_async_chunks(
outline_module.generate_ppt_outline(
content="Current facts",
n_slides=1,
language="English",
web_search=True,
emit_statuses=True,
)
)
statuses = [
chunk.message
for chunk in chunks
if isinstance(chunk, outline_module.OutlineGenerationStatus)
]
assert statuses == [
"Searching with model-native web search and drafting outlines"
]
def test_generate_ppt_outline_uses_fallback_query_when_query_generation_fails():
searched_queries = []
async def fake_stream_generate_events(_client, **_kwargs):
yield content_event('{"slides": [{"content": "## Current facts"}]}')
async def fail_query_generation(*_args):
raise RuntimeError("local model cannot generate structured query")
async def capture_search_context(query):
searched_queries.append(query)
return ""
with patch.object(outline_module, "get_model", return_value="fake-model"), patch.object(
outline_module, "get_client", return_value=object()
), patch.object(outline_module, "get_llm_config", return_value={}), patch.object(
outline_module, "should_use_native_web_search", return_value=False
), patch.object(
outline_module, "should_expose_external_web_search_tool", return_value=True
), patch.object(
outline_module,
"generate_web_search_query",
side_effect=fail_query_generation,
), patch.object(
outline_module,
"get_web_search_context",
side_effect=capture_search_context,
), patch.object(
outline_module,
"get_generate_kwargs",
side_effect=lambda **kwargs: kwargs,
), patch.object(
outline_module, "stream_generate_events", side_effect=fake_stream_generate_events
):
_collect_async_chunks(
outline_module.generate_ppt_outline(
content="current market",
n_slides=1,
language="English",
instructions="focus on Nepal",
web_search=True,
)
)
assert searched_queries == ["current market focus on Nepal"]
def test_generate_ppt_outline_uses_fallback_query_when_generated_query_is_null():
searched_queries = []
async def fake_stream_generate_events(_client, **_kwargs):
yield content_event('{"slides": [{"content": "## Supplied facts"}]}')
async def capture_search_context(query):
searched_queries.append(query)
return ""
with patch.object(outline_module, "get_model", return_value="fake-model"), patch.object(
outline_module, "get_client", return_value=object()
), patch.object(outline_module, "get_llm_config", return_value={}), patch.object(
outline_module, "should_use_native_web_search", return_value=False
), patch.object(
outline_module, "should_expose_external_web_search_tool", return_value=True
), patch.object(
outline_module,
"generate_web_search_query",
return_value=None,
), patch.object(
outline_module,
"get_web_search_context",
side_effect=capture_search_context,
), patch.object(
outline_module,
"get_generate_kwargs",
side_effect=lambda **kwargs: kwargs,
), patch.object(
outline_module, "stream_generate_events", side_effect=fake_stream_generate_events
):
_collect_async_chunks(
outline_module.generate_ppt_outline(
content="complete supplied report",
n_slides=1,
language="English",
web_search=True,
)
)
assert searched_queries == ["complete supplied report"]
def test_presentation_outline_model_schema_validation_rejects_invalid_ai_payload():
invalid_payload = {"slides": [{"not_content": "missing expected key"}]}
with pytest.raises(ValidationError):
PresentationOutlineModel.model_validate(invalid_payload)