1018 lines
35 KiB
Python
1018 lines
35 KiB
Python
import json
|
|
import logging
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from llmai.shared import (
|
|
AssistantMessage,
|
|
AssistantToolCall,
|
|
GoogleClientConfig,
|
|
ImageContentPart,
|
|
SystemMessage,
|
|
ToolResponseMessage,
|
|
UserMessage,
|
|
)
|
|
from pydantic import BaseModel, Field, ValidationError
|
|
|
|
from templates.v2.generation import (
|
|
CLUSTER_SIMILAR_COMPONENTS_SYSTEM_PROMPT,
|
|
CONTENT_ICON_PLACEHOLDER_URL,
|
|
CONTENT_IMAGE_PLACEHOLDER_URL,
|
|
GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT,
|
|
_generate_preview_candidate,
|
|
_messages_for_json_repair_retry,
|
|
_messages_for_model_validation_retry,
|
|
_slide_image_content,
|
|
_validate_similarity_groups,
|
|
generate_slide_layout,
|
|
generate_template,
|
|
merge_similar_components,
|
|
)
|
|
from templates.v2.models.elements import Image as TemplateImage
|
|
from templates.v2.models.layouts import (
|
|
RawSlideLayout,
|
|
RawSlideLayouts,
|
|
SimilarComponents,
|
|
SimilarComponentsList,
|
|
SlideLayout,
|
|
SlideLayouts,
|
|
)
|
|
from templates.v2.tools import PreviewSlideTool
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, content, messages=None, tool_calls=None):
|
|
self.content = content
|
|
self.messages = messages or []
|
|
self.tool_calls = tool_calls or []
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self, content=None, responses=None):
|
|
self.content = content
|
|
self.responses = list(responses or [])
|
|
self.calls = []
|
|
|
|
def generate(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
if self.responses:
|
|
return self.responses.pop(0)
|
|
return _FakeResponse(self.content)
|
|
|
|
|
|
class _ProviderResponseItem:
|
|
id = "rs_00000000000000000000000000000000"
|
|
|
|
|
|
class _RetrySchema(BaseModel):
|
|
title: str = Field(min_length=5)
|
|
|
|
|
|
def _raw_layout(layout_id: str = "source_slide") -> RawSlideLayout:
|
|
return RawSlideLayout.model_validate(
|
|
{
|
|
"id": layout_id,
|
|
"description": "Source slide with a title block.",
|
|
"elements": [
|
|
{
|
|
"type": "text",
|
|
"position": {"x": 100, "y": 80},
|
|
"size": {"width": 600, "height": 80},
|
|
"decorative": False,
|
|
"name": "title",
|
|
"min_length": 20,
|
|
"max_length": 40,
|
|
"runs": [{"text": "Original title"}],
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
def _generated_layout(layout_id: str = "title_slide") -> dict:
|
|
return {
|
|
"id": layout_id,
|
|
"description": "Reusable slide with a prominent title block.",
|
|
"components": [
|
|
{
|
|
"id": "title_block",
|
|
"description": "Reusable prominent title text block.",
|
|
"position": {"x": 100, "y": 80},
|
|
"size": {"width": 600, "height": 80},
|
|
"elements": [
|
|
{
|
|
"type": "text",
|
|
"position": {"x": 0, "y": 0},
|
|
"size": {"width": 600, "height": 80},
|
|
"decorative": False,
|
|
"name": "title",
|
|
"min_length": 20,
|
|
"max_length": 40,
|
|
"runs": [{"text": "Original title"}],
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def _generated_layout_with_images() -> dict:
|
|
layout = _generated_layout("image_slide")
|
|
layout["components"][0]["elements"] = [
|
|
{
|
|
"type": "image",
|
|
"position": {"x": 0, "y": 0},
|
|
"size": {"width": 320, "height": 180},
|
|
"decorative": False,
|
|
"name": "hero_image",
|
|
"data": "/app_data/images/source-photo.png",
|
|
"prompt": "Team reviewing dashboard",
|
|
"is_icon": False,
|
|
},
|
|
{
|
|
"type": "image",
|
|
"position": {"x": 340, "y": 0},
|
|
"size": {"width": 48, "height": 48},
|
|
"decorative": False,
|
|
"name": "status_icon",
|
|
"data": "/app_data/icons/source-icon.svg",
|
|
"prompt": "growth chart",
|
|
"is_icon": True,
|
|
},
|
|
{
|
|
"type": "image",
|
|
"position": {"x": 400, "y": 0},
|
|
"size": {"width": 80, "height": 40},
|
|
"decorative": True,
|
|
"name": "logo",
|
|
"data": "/app_data/images/logo.png",
|
|
"is_icon": False,
|
|
},
|
|
{
|
|
"type": "group",
|
|
"position": {"x": 0, "y": 220},
|
|
"size": {"width": 180, "height": 120},
|
|
"name": "nested_media",
|
|
"children": [
|
|
{
|
|
"type": "image",
|
|
"position": {"x": 0, "y": 0},
|
|
"size": {"width": 180, "height": 120},
|
|
"decorative": False,
|
|
"name": "nested_image",
|
|
"data": "/app_data/images/nested-photo.png",
|
|
"is_icon": False,
|
|
}
|
|
],
|
|
},
|
|
]
|
|
return layout
|
|
|
|
|
|
def _contains_key(value, key: str) -> bool:
|
|
if isinstance(value, dict):
|
|
return key in value or any(
|
|
_contains_key(child, key) for child in value.values()
|
|
)
|
|
if isinstance(value, list):
|
|
return any(_contains_key(item, key) for item in value)
|
|
return False
|
|
|
|
|
|
def test_template_image_supports_optional_overlay_color():
|
|
image = TemplateImage.model_validate(
|
|
{
|
|
"type": "image",
|
|
"data": "/app_data/image.png",
|
|
"color": "rgba(0, 0, 0, 0.35)",
|
|
"decorative": True,
|
|
"name": "background",
|
|
"is_icon": False,
|
|
}
|
|
)
|
|
image_without_overlay = TemplateImage.model_validate(
|
|
{
|
|
"type": "image",
|
|
"data": "/app_data/image.png",
|
|
"decorative": True,
|
|
"name": "background",
|
|
"is_icon": False,
|
|
}
|
|
)
|
|
|
|
assert image.color == "rgba(0, 0, 0, 0.35)"
|
|
assert image_without_overlay.color is None
|
|
|
|
|
|
def test_generate_slide_layout_requests_complete_layout(monkeypatch, caplog):
|
|
preview_tool_call = AssistantToolCall(
|
|
id="preview-call-1",
|
|
name="previewSlide",
|
|
arguments=json.dumps(_generated_layout()),
|
|
)
|
|
client = _FakeClient(
|
|
responses=[
|
|
_FakeResponse(None, tool_calls=[preview_tool_call]),
|
|
_FakeResponse(_generated_layout()),
|
|
]
|
|
)
|
|
monkeypatch.setattr("templates.v2.generation.get_client", lambda **_kwargs: client)
|
|
monkeypatch.setattr("templates.v2.generation.get_llm_config", lambda: {})
|
|
monkeypatch.setattr("templates.v2.generation.get_model", lambda: "test-model")
|
|
monkeypatch.setattr(
|
|
PreviewSlideTool,
|
|
"render",
|
|
lambda _self, _layout: ImageContentPart(
|
|
data=b"rendered-preview",
|
|
mime_type="image/png",
|
|
),
|
|
)
|
|
caplog.set_level(logging.INFO, logger="templates.v2.generation")
|
|
|
|
result = generate_slide_layout(
|
|
_raw_layout(),
|
|
2,
|
|
"https://example.com/slide-3.png",
|
|
)
|
|
|
|
assert result == SlideLayout.model_validate(_generated_layout())
|
|
result_element = result.model_dump(mode="json")["components"][0]["elements"][0]
|
|
assert result_element["decorative"] is False
|
|
assert "fixed" not in result_element
|
|
assert len(client.calls) == 2
|
|
preview_call = client.calls[0]
|
|
assert isinstance(preview_call["tools"][0], PreviewSlideTool)
|
|
assert preview_call["tools"][0].input_schema is SlideLayout
|
|
assert preview_call["tool_choice"] == {
|
|
"mode": "auto",
|
|
"tools": ["previewSlide"],
|
|
}
|
|
assert preview_call["response_format"].json_schema is SlideLayout
|
|
assert preview_call["response_format"].name == "SlideLayoutResponse"
|
|
assert "max_tokens" not in preview_call
|
|
assert preview_call["messages"][0].content == GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT
|
|
user_content = preview_call["messages"][1].content
|
|
assert user_content[0].url == "https://example.com/slide-3.png"
|
|
payload = json.loads(user_content[1])
|
|
assert payload[0]["id"] == "source_slide"
|
|
assert payload[0]["elements"][0]["runs"][0]["text"] == (
|
|
"Original title"
|
|
)
|
|
assert not _contains_key(payload, "decorative")
|
|
|
|
final_call = client.calls[1]
|
|
assert final_call["response_format"].json_schema is SlideLayout
|
|
assert final_call["response_format"].name == "SlideLayoutResponse"
|
|
assert "max_tokens" not in final_call
|
|
assert isinstance(final_call["messages"][-2], ToolResponseMessage)
|
|
feedback = final_call["messages"][-1]
|
|
assert isinstance(feedback, UserMessage)
|
|
assert feedback.content[0].data == b"rendered-preview"
|
|
assert "Review this rendered candidate" in feedback.content[1]
|
|
messages = [record.getMessage() for record in caplog.records]
|
|
assert any("slide 3: preview slide called" in message for message in messages)
|
|
assert any("slide 3: preview slide rendered" in message for message in messages)
|
|
assert any("slide 3: slide layout JSON returned" in message for message in messages)
|
|
|
|
|
|
def test_generate_slide_layout_accepts_direct_schema_response(monkeypatch, caplog):
|
|
client = _FakeClient(responses=[_FakeResponse(_generated_layout())])
|
|
monkeypatch.setattr("templates.v2.generation.get_client", lambda **_kwargs: client)
|
|
monkeypatch.setattr("templates.v2.generation.get_llm_config", lambda: {})
|
|
monkeypatch.setattr("templates.v2.generation.get_model", lambda: "test-model")
|
|
monkeypatch.setattr(
|
|
PreviewSlideTool,
|
|
"render",
|
|
lambda _self, _layout: pytest.fail("preview should not be rendered"),
|
|
)
|
|
caplog.set_level(logging.INFO, logger="templates.v2.generation")
|
|
|
|
result = generate_slide_layout(
|
|
_raw_layout(),
|
|
0,
|
|
"https://example.com/slide-1.png",
|
|
)
|
|
|
|
assert result == SlideLayout.model_validate(_generated_layout())
|
|
assert len(client.calls) == 1
|
|
call = client.calls[0]
|
|
assert call["tool_choice"] == {
|
|
"mode": "auto",
|
|
"tools": ["previewSlide"],
|
|
}
|
|
assert call["response_format"].json_schema is SlideLayout
|
|
assert call["response_format"].name == "SlideLayoutResponse"
|
|
messages = [record.getMessage() for record in caplog.records]
|
|
assert any("slide 1: slide layout JSON returned" in message for message in messages)
|
|
|
|
|
|
def test_generate_slide_layout_replaces_content_image_urls(monkeypatch):
|
|
client = _FakeClient(responses=[_FakeResponse(_generated_layout_with_images())])
|
|
monkeypatch.setattr("templates.v2.generation.get_client", lambda **_kwargs: client)
|
|
monkeypatch.setattr("templates.v2.generation.get_llm_config", lambda: {})
|
|
monkeypatch.setattr("templates.v2.generation.get_model", lambda: "test-model")
|
|
monkeypatch.setattr(
|
|
PreviewSlideTool,
|
|
"render",
|
|
lambda _self, _layout: pytest.fail("preview should not be rendered"),
|
|
)
|
|
|
|
result = generate_slide_layout(
|
|
_raw_layout(),
|
|
0,
|
|
"https://example.com/slide-1.png",
|
|
)
|
|
|
|
elements = result.model_dump(mode="json")["components"][0]["elements"]
|
|
assert elements[0]["data"] == CONTENT_IMAGE_PLACEHOLDER_URL
|
|
assert elements[0]["prompt"] == "Team reviewing dashboard"
|
|
assert elements[1]["data"] == CONTENT_ICON_PLACEHOLDER_URL
|
|
assert elements[1]["prompt"] == "growth chart"
|
|
assert elements[2]["data"] == "/app_data/images/logo.png"
|
|
assert elements[3]["children"][0]["data"] == CONTENT_IMAGE_PLACEHOLDER_URL
|
|
|
|
|
|
def test_generate_slide_layout_passes_max_tokens_when_provided(monkeypatch):
|
|
client = _FakeClient(responses=[_FakeResponse(_generated_layout())])
|
|
monkeypatch.setattr("templates.v2.generation.get_client", lambda **_kwargs: client)
|
|
monkeypatch.setattr("templates.v2.generation.get_llm_config", lambda: {})
|
|
monkeypatch.setattr("templates.v2.generation.get_model", lambda: "test-model")
|
|
monkeypatch.setattr(
|
|
PreviewSlideTool,
|
|
"render",
|
|
lambda _self, _layout: pytest.fail("preview should not be rendered"),
|
|
)
|
|
|
|
result = generate_slide_layout(
|
|
_raw_layout(),
|
|
0,
|
|
"https://example.com/slide-1.png",
|
|
max_tokens=16000,
|
|
)
|
|
|
|
assert result == SlideLayout.model_validate(_generated_layout())
|
|
assert client.calls[0]["max_tokens"] == 16000
|
|
|
|
|
|
def test_generate_slide_layout_uses_json_schema_response_for_google(monkeypatch):
|
|
client = _FakeClient(responses=[_FakeResponse(_generated_layout())])
|
|
monkeypatch.setattr("templates.v2.generation.get_client", lambda **_kwargs: client)
|
|
monkeypatch.setattr(
|
|
"templates.v2.generation.get_llm_config",
|
|
lambda: GoogleClientConfig(api_key="test-key"),
|
|
)
|
|
monkeypatch.setattr("templates.v2.generation.get_model", lambda: "gemini-test")
|
|
monkeypatch.setattr(
|
|
PreviewSlideTool,
|
|
"render",
|
|
lambda _self, _layout: pytest.fail("preview should not be rendered"),
|
|
)
|
|
|
|
result = generate_slide_layout(
|
|
_raw_layout(),
|
|
0,
|
|
"https://example.com/slide-1.png",
|
|
)
|
|
|
|
assert result == SlideLayout.model_validate(_generated_layout())
|
|
call = client.calls[0]
|
|
assert call["response_format"].json_schema is SlideLayout
|
|
assert call["response_format"].name == "SlideLayoutResponse"
|
|
assert call["messages"][0].content == GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT
|
|
|
|
|
|
def test_generate_preview_candidate_returns_last_preview_tool_json(monkeypatch, caplog):
|
|
preview_tool_call = AssistantToolCall(
|
|
id="preview-call-1",
|
|
name="previewSlide",
|
|
arguments=json.dumps(_generated_layout()),
|
|
)
|
|
client = _FakeClient(
|
|
responses=[_FakeResponse(None, tool_calls=[preview_tool_call])]
|
|
)
|
|
render_calls = []
|
|
|
|
def fake_render(_self, layout):
|
|
render_calls.append(layout.id)
|
|
return ImageContentPart(
|
|
data=b"rendered-preview",
|
|
mime_type="image/png",
|
|
)
|
|
|
|
monkeypatch.setattr(PreviewSlideTool, "render", fake_render)
|
|
caplog.set_level(logging.INFO, logger="templates.v2.generation")
|
|
|
|
result = _generate_preview_candidate(
|
|
client=client,
|
|
model="test-model",
|
|
messages=[
|
|
SystemMessage(content=GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT),
|
|
UserMessage(content="{}"),
|
|
],
|
|
label="slide layout",
|
|
preview_tool=PreviewSlideTool(),
|
|
validation_retries=0,
|
|
)
|
|
|
|
assert result == SlideLayout.model_validate(_generated_layout())
|
|
assert render_calls == ["title_slide"]
|
|
assert len(client.calls) == 1
|
|
call = client.calls[0]
|
|
assert call["response_format"].json_schema is SlideLayout
|
|
assert "max_tokens" not in call
|
|
messages = [record.getMessage() for record in caplog.records]
|
|
assert any(
|
|
"slide layout: preview slide rendered" in message
|
|
for message in messages
|
|
)
|
|
assert any(
|
|
"slide layout: returning preview slide JSON as final" in message
|
|
for message in messages
|
|
)
|
|
|
|
|
|
def test_generate_preview_candidate_preserves_provider_response_messages(monkeypatch):
|
|
preview_tool_call = AssistantToolCall(
|
|
id="preview-call-1",
|
|
name="previewSlide",
|
|
arguments=json.dumps(_generated_layout("first_candidate")),
|
|
)
|
|
preserved_assistant_message = AssistantMessage(
|
|
content=["provider-preserved-context"],
|
|
tool_calls=[preview_tool_call],
|
|
)
|
|
initial_messages = [
|
|
SystemMessage(content=GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT),
|
|
UserMessage(content="{}"),
|
|
]
|
|
client = _FakeClient(
|
|
responses=[
|
|
_FakeResponse(
|
|
None,
|
|
messages=[*initial_messages, preserved_assistant_message],
|
|
tool_calls=[preview_tool_call],
|
|
),
|
|
_FakeResponse(_generated_layout("final_candidate")),
|
|
]
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
PreviewSlideTool,
|
|
"render",
|
|
lambda _self, _layout: ImageContentPart(
|
|
data=b"rendered-preview",
|
|
mime_type="image/png",
|
|
),
|
|
)
|
|
|
|
result = _generate_preview_candidate(
|
|
client=client,
|
|
model="test-model",
|
|
messages=initial_messages,
|
|
label="slide layout",
|
|
preview_tool=PreviewSlideTool(),
|
|
validation_retries=1,
|
|
)
|
|
|
|
assert result == SlideLayout.model_validate(_generated_layout("final_candidate"))
|
|
follow_up_messages = client.calls[1]["messages"]
|
|
assert follow_up_messages[2] is preserved_assistant_message
|
|
assert follow_up_messages[3].id == "preview-call-1"
|
|
assert follow_up_messages[4].content[0].data == b"rendered-preview"
|
|
assert "Original slide image:" not in follow_up_messages[4].content
|
|
|
|
|
|
def test_generate_slide_layout_allows_second_preview_then_returns_final_json(
|
|
monkeypatch,
|
|
caplog,
|
|
):
|
|
first_preview_tool_call = AssistantToolCall(
|
|
id="preview-call-1",
|
|
name="previewSlide",
|
|
arguments=json.dumps(_generated_layout("first_candidate")),
|
|
)
|
|
second_preview_tool_call = AssistantToolCall(
|
|
id="preview-call-2",
|
|
name="previewSlide",
|
|
arguments=json.dumps(_generated_layout("second_candidate")),
|
|
)
|
|
client = _FakeClient(
|
|
responses=[
|
|
_FakeResponse(None, tool_calls=[first_preview_tool_call]),
|
|
_FakeResponse(None, tool_calls=[second_preview_tool_call]),
|
|
_FakeResponse(_generated_layout("final_candidate")),
|
|
]
|
|
)
|
|
render_calls = []
|
|
|
|
def fake_render(_self, layout):
|
|
render_calls.append(layout.id)
|
|
return ImageContentPart(
|
|
data=b"rendered-preview",
|
|
mime_type="image/png",
|
|
)
|
|
|
|
monkeypatch.setattr("templates.v2.generation.get_client", lambda **_kwargs: client)
|
|
monkeypatch.setattr("templates.v2.generation.get_llm_config", lambda: {})
|
|
monkeypatch.setattr("templates.v2.generation.get_model", lambda: "test-model")
|
|
monkeypatch.setattr(PreviewSlideTool, "render", fake_render)
|
|
caplog.set_level(logging.INFO, logger="templates.v2.generation")
|
|
|
|
result = generate_slide_layout(
|
|
_raw_layout(),
|
|
0,
|
|
"https://example.com/slide-1.png",
|
|
)
|
|
|
|
assert result == SlideLayout.model_validate(_generated_layout("final_candidate"))
|
|
assert render_calls == ["first_candidate", "second_candidate"]
|
|
assert len(client.calls) == 3
|
|
second_call = client.calls[1]
|
|
assert isinstance(second_call["messages"][-2], ToolResponseMessage)
|
|
assert second_call["messages"][-2].id == "preview-call-1"
|
|
second_feedback = second_call["messages"][-1]
|
|
assert isinstance(second_feedback, UserMessage)
|
|
assert second_feedback.content[0].data == b"rendered-preview"
|
|
assert "one more time" in second_feedback.content[1]
|
|
third_call = client.calls[2]
|
|
assert "tools" not in third_call
|
|
assert "tool_choice" not in third_call
|
|
assert isinstance(third_call["messages"][-2], ToolResponseMessage)
|
|
assert third_call["messages"][-2].id == "preview-call-2"
|
|
final_feedback = third_call["messages"][-1]
|
|
assert isinstance(final_feedback, UserMessage)
|
|
assert final_feedback.content[0].data == b"rendered-preview"
|
|
assert "maximum number of previewSlide calls" in final_feedback.content[1]
|
|
messages = [record.getMessage() for record in caplog.records]
|
|
assert any("slide 1: preview slide called" in message for message in messages)
|
|
assert any("preview_call=2" in message for message in messages)
|
|
assert any(
|
|
"slide 1: slide layout JSON returned" in message
|
|
for message in messages
|
|
)
|
|
|
|
|
|
def test_generate_template_generates_each_slide_and_preserves_order(monkeypatch):
|
|
raw_layouts = RawSlideLayouts(
|
|
layouts=[_raw_layout("first"), _raw_layout("second")]
|
|
)
|
|
calls = []
|
|
|
|
def fake_generate(source_layout, slide_index, slide_image_url, fonts=None):
|
|
calls.append((source_layout.id, slide_index, slide_image_url, fonts))
|
|
return SlideLayout.model_validate(
|
|
_generated_layout(f"generated_{source_layout.id}")
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"templates.v2.generation.generate_slide_layout", fake_generate
|
|
)
|
|
|
|
generated = generate_template(
|
|
raw_layouts,
|
|
["https://example.com/first.png", "https://example.com/second.png"],
|
|
{"Inter": "https://example.com/inter.css"},
|
|
)
|
|
|
|
assert sorted(calls) == [
|
|
(
|
|
"first",
|
|
0,
|
|
"https://example.com/first.png",
|
|
{"Inter": "https://example.com/inter.css"},
|
|
),
|
|
(
|
|
"second",
|
|
1,
|
|
"https://example.com/second.png",
|
|
{"Inter": "https://example.com/inter.css"},
|
|
),
|
|
]
|
|
assert [layout.id for layout in generated.layouts] == [
|
|
"generated_first",
|
|
"generated_second",
|
|
]
|
|
|
|
|
|
def test_generate_template_repairs_duplicate_generated_layout_ids(monkeypatch):
|
|
raw_layouts = RawSlideLayouts(
|
|
layouts=[_raw_layout("first"), _raw_layout("second")]
|
|
)
|
|
|
|
def fake_generate(source_layout, slide_index, slide_image_url, fonts=None):
|
|
return SlideLayout.model_validate(_generated_layout("duplicate_layout"))
|
|
|
|
monkeypatch.setattr(
|
|
"templates.v2.generation.generate_slide_layout", fake_generate
|
|
)
|
|
|
|
generated = generate_template(
|
|
raw_layouts,
|
|
["https://example.com/first.png", "https://example.com/second.png"],
|
|
)
|
|
|
|
assert [layout.id for layout in generated.layouts] == [
|
|
"duplicate_layout",
|
|
"duplicate_layout_2",
|
|
]
|
|
|
|
|
|
def test_generate_template_rejects_empty_source():
|
|
with pytest.raises(ValueError, match="at least one"):
|
|
generate_template(RawSlideLayouts(layouts=[]), [])
|
|
|
|
|
|
def test_generate_template_requires_one_image_per_layout():
|
|
with pytest.raises(ValueError, match="one image for each layout"):
|
|
generate_template(
|
|
RawSlideLayouts(layouts=[_raw_layout("first"), _raw_layout("second")]),
|
|
["https://example.com/first.png"],
|
|
)
|
|
|
|
|
|
def test_merge_similar_components_clusters_by_global_component_index(
|
|
monkeypatch, caplog
|
|
):
|
|
first = _generated_layout("first_layout")
|
|
first["components"][0]["id"] = "title_block"
|
|
first["components"][0]["description"] = (
|
|
"Reusable prominent title text block for opening slides."
|
|
)
|
|
second = _generated_layout("second_layout")
|
|
second["components"][0]["id"] = "metric_grid"
|
|
second["components"][0]["description"] = (
|
|
"Reusable grid presenting several business metrics and labels."
|
|
)
|
|
second["components"][0]["size"] = {"width": 600, "height": 180}
|
|
second["components"][0]["elements"] = [
|
|
{
|
|
"type": "grid",
|
|
"position": {"x": 0, "y": 0},
|
|
"size": {"width": 600, "height": 180},
|
|
"columns": 2,
|
|
"rows": 1,
|
|
"gap": 24,
|
|
"name": "metrics",
|
|
"min_children": 1,
|
|
"max_children": 2,
|
|
"children": [
|
|
{
|
|
"type": "text",
|
|
"size": {"width": 280, "height": 80},
|
|
"decorative": False,
|
|
"name": "metric_value",
|
|
"min_length": 1,
|
|
"max_length": 10,
|
|
"runs": [{"text": "42%"}],
|
|
},
|
|
{
|
|
"type": "text",
|
|
"size": {"width": 280, "height": 80},
|
|
"decorative": False,
|
|
"name": "metric_label",
|
|
"min_length": 5,
|
|
"max_length": 30,
|
|
"runs": [{"text": "Revenue growth"}],
|
|
},
|
|
],
|
|
}
|
|
]
|
|
third = _generated_layout("third_layout")
|
|
third["components"][0]["id"] = "section_heading"
|
|
third["components"][0]["description"] = (
|
|
"Reusable prominent heading text block for section slides."
|
|
)
|
|
layouts = SlideLayouts.model_validate({"layouts": [first, second, third]})
|
|
client = _FakeClient(
|
|
{
|
|
"similar_components": [
|
|
{"indices": [0, 2]},
|
|
]
|
|
}
|
|
)
|
|
monkeypatch.setattr("templates.v2.generation.get_client", lambda **_kwargs: client)
|
|
monkeypatch.setattr("templates.v2.generation.get_llm_config", lambda: {})
|
|
monkeypatch.setattr("templates.v2.generation.get_model", lambda: "test-model")
|
|
caplog.set_level(logging.INFO, logger="templates.v2.generation")
|
|
|
|
merged = merge_similar_components(layouts)
|
|
|
|
assert len(merged.components) == 2
|
|
assert merged.components[0].id == "title_block"
|
|
assert [variant.id for variant in merged.components[0].variants] == [
|
|
"title_block",
|
|
"section_heading",
|
|
]
|
|
assert [variant.id for variant in merged.components[1].variants] == [
|
|
"metric_grid"
|
|
]
|
|
|
|
call = client.calls[0]
|
|
assert call["response_format"].json_schema.__name__ == "SimilarComponentsList"
|
|
assert call["response_format"].name == "SimilarComponentsResponse"
|
|
assert call["messages"][0].content == CLUSTER_SIMILAR_COMPONENTS_SYSTEM_PROMPT
|
|
payload = json.loads(call["messages"][1].content)
|
|
assert payload == {
|
|
"components": [
|
|
{
|
|
"index": 0,
|
|
"id": "title_block",
|
|
"description": (
|
|
"Reusable prominent title text block for opening slides."
|
|
),
|
|
},
|
|
{
|
|
"index": 1,
|
|
"id": "metric_grid",
|
|
"description": (
|
|
"Reusable grid presenting several business metrics and labels."
|
|
),
|
|
},
|
|
{
|
|
"index": 2,
|
|
"id": "section_heading",
|
|
"description": (
|
|
"Reusable prominent heading text block for section slides."
|
|
),
|
|
},
|
|
]
|
|
}
|
|
messages = "\n".join(record.getMessage() for record in caplog.records)
|
|
assert "similar_components" not in messages
|
|
assert "schema=SimilarComponentsResponse" in messages
|
|
|
|
|
|
def test_merge_similar_components_skips_llm_for_single_component(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"templates.v2.generation.get_client",
|
|
lambda **_kwargs: pytest.fail("LLM should not be called"),
|
|
)
|
|
layouts = SlideLayouts.model_validate({"layouts": [_generated_layout()]})
|
|
|
|
merged = merge_similar_components(layouts)
|
|
|
|
assert len(merged.components) == 1
|
|
assert merged.components[0].id == "title_block"
|
|
assert len(merged.components[0].variants) == 1
|
|
|
|
|
|
def test_merge_similar_components_removes_structural_duplicates_after_clustering(
|
|
monkeypatch,
|
|
):
|
|
first = _generated_layout("first_layout")
|
|
first["components"][0]["id"] = "headline_a"
|
|
first["components"][0]["description"] = (
|
|
"Reusable headline card with static divider decoration."
|
|
)
|
|
first["components"][0]["elements"] = [
|
|
{
|
|
"type": "rectangle",
|
|
"position": {"x": 0, "y": 70},
|
|
"size": {"width": 600, "height": 4},
|
|
"fill": {"color": "#111111"},
|
|
},
|
|
{
|
|
"type": "text",
|
|
"position": {"x": 0, "y": 0},
|
|
"size": {"width": 600, "height": 60},
|
|
"decorative": False,
|
|
"name": "headline",
|
|
"min_length": 5,
|
|
"max_length": 60,
|
|
"runs": [{"text": "First headline content"}],
|
|
},
|
|
]
|
|
second = _generated_layout("second_layout")
|
|
second["components"][0]["id"] = "headline_b"
|
|
second["components"][0]["description"] = (
|
|
"Reusable title card with the same static divider decoration."
|
|
)
|
|
second["components"][0]["position"] = {"x": 260, "y": 180}
|
|
second["components"][0]["elements"] = [
|
|
{
|
|
"type": "rectangle",
|
|
"position": {"x": 0, "y": 70},
|
|
"size": {"width": 600, "height": 4},
|
|
"fill": {"color": "#111111"},
|
|
},
|
|
{
|
|
"type": "text",
|
|
"position": {"x": 0, "y": 0},
|
|
"size": {"width": 600, "height": 60},
|
|
"decorative": False,
|
|
"name": "title",
|
|
"min_length": 5,
|
|
"max_length": 80,
|
|
"runs": [{"text": "Different editable title copy"}],
|
|
},
|
|
]
|
|
third = _generated_layout("third_layout")
|
|
third["components"][0]["id"] = "headline_c"
|
|
third["components"][0]["description"] = (
|
|
"Reusable headline card with a different static divider decoration."
|
|
)
|
|
third["components"][0]["elements"] = [
|
|
{
|
|
"type": "rectangle",
|
|
"position": {"x": 0, "y": 70},
|
|
"size": {"width": 600, "height": 4},
|
|
"fill": {"color": "#DDDDDD"},
|
|
},
|
|
{
|
|
"type": "text",
|
|
"position": {"x": 0, "y": 0},
|
|
"size": {"width": 600, "height": 60},
|
|
"decorative": False,
|
|
"name": "headline",
|
|
"min_length": 5,
|
|
"max_length": 60,
|
|
"runs": [{"text": "Third headline content"}],
|
|
},
|
|
]
|
|
layouts = SlideLayouts.model_validate({"layouts": [first, second, third]})
|
|
client = _FakeClient({"similar_components": []})
|
|
monkeypatch.setattr("templates.v2.generation.get_client", lambda **_kwargs: client)
|
|
monkeypatch.setattr("templates.v2.generation.get_llm_config", lambda: {})
|
|
monkeypatch.setattr("templates.v2.generation.get_model", lambda: "test-model")
|
|
|
|
merged = merge_similar_components(layouts)
|
|
|
|
assert len(client.calls) == 1
|
|
assert len(merged.components) == 2
|
|
assert [variant.id for variant in merged.components[0].variants] == [
|
|
"headline_a",
|
|
"headline_b",
|
|
]
|
|
assert [variant.id for variant in merged.components[1].variants] == [
|
|
"headline_c",
|
|
]
|
|
|
|
|
|
def test_similar_components_requires_unique_non_negative_indices():
|
|
with pytest.raises(ValidationError, match="must be unique"):
|
|
SimilarComponents(indices=[1, 1])
|
|
with pytest.raises(ValidationError, match="non-negative"):
|
|
SimilarComponents(indices=[-1, 1])
|
|
|
|
|
|
def test_similarity_groups_reject_overlapping_and_out_of_range_indices():
|
|
overlapping = SimilarComponentsList.model_validate(
|
|
{
|
|
"similar_components": [
|
|
{"indices": [0, 1]},
|
|
{"indices": [1, 2]},
|
|
]
|
|
}
|
|
)
|
|
with pytest.raises(ValueError, match="more than one"):
|
|
_validate_similarity_groups(overlapping, component_count=3)
|
|
|
|
out_of_range = SimilarComponentsList.model_validate(
|
|
{"similar_components": [{"indices": [0, 3]}]}
|
|
)
|
|
with pytest.raises(ValueError, match="outside the available range"):
|
|
_validate_similarity_groups(out_of_range, component_count=3)
|
|
|
|
|
|
def test_slide_image_content_embeds_local_image_bytes(tmp_path, monkeypatch):
|
|
image_path = tmp_path / "slide.png"
|
|
image_path.write_bytes(b"png-image-bytes")
|
|
monkeypatch.setattr(
|
|
"templates.v2.generation.resolve_image_path_to_filesystem",
|
|
lambda _url: str(image_path),
|
|
)
|
|
|
|
image_content = _slide_image_content("/app_data/images/slide.png")
|
|
|
|
assert image_content.data == b"png-image-bytes"
|
|
assert image_content.mime_type == "image/png"
|
|
assert image_content.url is None
|
|
|
|
|
|
def test_preview_slide_tool_renders_layout_components(tmp_path, monkeypatch):
|
|
app_data_dir = tmp_path / "app-data"
|
|
preview_path = tmp_path / "preview.png"
|
|
preview_path.write_bytes(b"rendered-slide")
|
|
captured = {}
|
|
|
|
async def fake_render_json_to_image(data, width, height, fonts=None):
|
|
captured["data"] = data
|
|
captured["width"] = width
|
|
captured["height"] = height
|
|
captured["fonts"] = fonts
|
|
return SimpleNamespace(path=str(preview_path))
|
|
|
|
monkeypatch.setattr(
|
|
"templates.v2.tools.EXPORT_TASK_SERVICE.render_json_to_image",
|
|
fake_render_json_to_image,
|
|
)
|
|
monkeypatch.setenv("APP_DATA_DIRECTORY", str(app_data_dir))
|
|
|
|
image = PreviewSlideTool(
|
|
slide_index=2,
|
|
fonts={"Inter": "https://example.com/inter.css"},
|
|
).render(
|
|
SlideLayout.model_validate(_generated_layout())
|
|
)
|
|
|
|
saved_json_path = app_data_dir / "preview_slide" / "2" / "1.json"
|
|
saved_image_path = app_data_dir / "preview_slide" / "2" / "1.png"
|
|
|
|
assert captured["data"][0]["id"] == "title_block"
|
|
assert captured["data"][0]["elements"][0]["type"] == "text"
|
|
assert captured["width"] == 1280
|
|
assert captured["height"] == 720
|
|
assert captured["fonts"] == {"Inter": "https://example.com/inter.css"}
|
|
assert image.data == b"rendered-slide"
|
|
assert image.mime_type == "image/png"
|
|
assert json.loads(saved_json_path.read_text()) == _generated_layout()
|
|
assert saved_image_path.read_bytes() == b"rendered-slide"
|
|
|
|
|
|
def test_slide_layout_rejects_duplicate_component_ids():
|
|
layout = _generated_layout()
|
|
layout["components"].append(layout["components"][0])
|
|
|
|
with pytest.raises(ValidationError, match="component ids must be unique"):
|
|
SlideLayout.model_validate(layout)
|
|
|
|
|
|
def test_slide_layout_does_not_accept_fixed_component_metadata():
|
|
layout = _generated_layout()
|
|
element = layout["components"][0]["elements"][0]
|
|
element["fixed"] = element.pop("decorative")
|
|
|
|
with pytest.raises(ValidationError):
|
|
SlideLayout.model_validate(layout)
|
|
|
|
|
|
def test_direct_generation_prompt_uses_decorative_element_metadata():
|
|
assert "Convert the provided raw slide elements to components" in (
|
|
GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT
|
|
)
|
|
assert "# Decorative and Content Element Rules:" in (
|
|
GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT
|
|
)
|
|
assert "`decorative=true`" in GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT
|
|
assert "`decorative=false`" in GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT
|
|
assert "If removal changes meaning, it is content" in (
|
|
GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT
|
|
)
|
|
|
|
|
|
def test_json_repair_retry_rebuilds_messages_without_provider_response_items():
|
|
original_messages = [
|
|
SystemMessage(content="Return JSON."),
|
|
UserMessage(content="{}"),
|
|
]
|
|
provider_response_item = _ProviderResponseItem()
|
|
response = _FakeResponse(
|
|
content='{"bad": true',
|
|
messages=[provider_response_item],
|
|
)
|
|
|
|
retry_messages = _messages_for_json_repair_retry(
|
|
messages=original_messages,
|
|
response=response,
|
|
label="slide layout",
|
|
error=ValueError("invalid JSON"),
|
|
)
|
|
|
|
assert provider_response_item not in retry_messages
|
|
assert retry_messages[:2] == original_messages
|
|
assert isinstance(retry_messages[2], AssistantMessage)
|
|
assert retry_messages[2].content == ['"{\\"bad\\": true"']
|
|
assert isinstance(retry_messages[3], UserMessage)
|
|
assert "Return a complete replacement JSON object." in retry_messages[3].content
|
|
|
|
|
|
def test_validation_retry_rebuilds_messages_without_provider_response_items():
|
|
original_messages = [
|
|
SystemMessage(content="Return schema JSON."),
|
|
UserMessage(content='{"title":"ok"}'),
|
|
]
|
|
provider_response_item = _ProviderResponseItem()
|
|
invalid_response = {"title": "bad"}
|
|
response = _FakeResponse(
|
|
content=invalid_response,
|
|
messages=[provider_response_item],
|
|
)
|
|
with pytest.raises(ValidationError) as exc:
|
|
_RetrySchema.model_validate(invalid_response)
|
|
|
|
retry_messages = _messages_for_model_validation_retry(
|
|
messages=original_messages,
|
|
response=response,
|
|
label="slide layout",
|
|
output_model=_RetrySchema,
|
|
error=exc.value,
|
|
invalid_response=invalid_response,
|
|
)
|
|
|
|
assert provider_response_item not in retry_messages
|
|
assert retry_messages[:2] == original_messages
|
|
assert isinstance(retry_messages[2], AssistantMessage)
|
|
assert retry_messages[2].content == ['{\n "title": "bad"\n}']
|
|
assert isinstance(retry_messages[3], UserMessage)
|
|
assert "required_json_schema:" in retry_messages[3].content
|