1183 lines
41 KiB
Python
1183 lines
41 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import mimetypes
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from json import JSONDecodeError
|
|
from time import perf_counter
|
|
from typing import Any, Callable
|
|
|
|
from llmai import get_client
|
|
from llmai.shared import (
|
|
AssistantMessage,
|
|
ImageContentPart,
|
|
JSONSchemaResponse,
|
|
SystemMessage,
|
|
ToolChoice,
|
|
ToolChoiceMode,
|
|
ToolResponseMessage,
|
|
UserMessage,
|
|
)
|
|
from pydantic import BaseModel, ValidationError
|
|
|
|
from templates.v2.models.layouts import (
|
|
Component,
|
|
MergedComponent,
|
|
MergedComponents,
|
|
RawSlideLayout,
|
|
RawSlideLayouts,
|
|
SimilarComponentsList,
|
|
SlideLayout,
|
|
SlideLayouts,
|
|
)
|
|
from templates.v2.models.elements import Image as SlideImageElement
|
|
from templates.v2.tools import PREVIEW_SLIDE_TOOL_NAME, PreviewSlideTool
|
|
from utils.asset_directory_utils import resolve_image_path_to_filesystem
|
|
from utils.llm_config import get_llm_config
|
|
from utils.llm_provider import get_model
|
|
|
|
DEFAULT_VALIDATION_RETRIES = 5
|
|
MAX_PARALLEL_SLIDE_LAYOUTS = 10
|
|
MAX_PREVIEW_SLIDE_CALLS = 2
|
|
CONTENT_IMAGE_PLACEHOLDER_URL = "/static/images/replaceable_template_image.png"
|
|
CONTENT_ICON_PLACEHOLDER_URL = "/static/icons/placeholder.svg"
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
_DUPLICATE_POSITION_GRID_UNITS = 5
|
|
_IGNORED_DUPLICATE_SCHEMA_KEYS = {
|
|
"name",
|
|
"max_length",
|
|
"min_length",
|
|
"max_items",
|
|
"min_items",
|
|
"max_item_length",
|
|
"min_item_length",
|
|
"max_columns",
|
|
"min_columns",
|
|
"max_rows",
|
|
"min_rows",
|
|
"max_children",
|
|
"min_children",
|
|
}
|
|
_CONTENT_VALUE_KEYS_BY_ELEMENT_TYPE = {
|
|
"chart": {
|
|
"categories",
|
|
"series",
|
|
"source",
|
|
"title",
|
|
"title_color",
|
|
"x_axis_title",
|
|
"y_axis_title",
|
|
},
|
|
"image": {"data", "prompt"},
|
|
"infographic": {"max_value", "min_value", "value"},
|
|
"text": {"runs"},
|
|
"text-list": {"items"},
|
|
}
|
|
|
|
|
|
GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT = """
|
|
Convert the provided raw slide elements to components.
|
|
|
|
# Steps:
|
|
1. Analyze/Visualize the slide using provided raw pptx elements and image.
|
|
2. Divide the slide into a list of components using slide image.
|
|
3. Identify group of elements that belongs to each component.
|
|
4. Generate `id` and `description` for the layout.
|
|
5. Call `previewSlide` to visualize generated slide layout.
|
|
6. Return slide layout json if no issues are identified after `previewSlide`.
|
|
|
|
# General Rules:
|
|
- `id` and `description` must be related to layout and must not be derived from slide content.
|
|
- `id` should be about 2 to 5 words in snake_case format.
|
|
- `description` should be around 15 to 30 words.
|
|
- `name` of element must be derived from layout, not from content.
|
|
|
|
# Layout Rules:
|
|
- Build the flexible component layout using `flex`, `grid`, `container`, etc.
|
|
- Use `flex` and `grid` only for list of similar items arranged in list or grid.
|
|
- Use `table` element for table and `chart` element for chart.
|
|
- Use `infographic` element for infographic or metric visuals like `progress_bar`, `gauge`, etc.
|
|
- Use `text-list` element for list of text like bullet points, numbered list, unordered list, etc.
|
|
- Use `rectangle`, `ellipse`, `line` etc for geometry.
|
|
- Use `container` for flexible alignment and layout.
|
|
- Use `image` for images and icons.
|
|
- Identify icon color from slide image.
|
|
|
|
# Decorative and Content Element Rules:
|
|
- Use `decorative=false` for elements that carry slide meaning or should be replaced, including text, charts, tables, metrics, and primary images/icons.
|
|
- Use `decorative=true` for fixed styling or branding, including backgrounds, frames, dividers, accents, logos, watermarks, and ornamental images/icons.
|
|
- If removal changes meaning, it is content; if removal only changes style, it is decorative.
|
|
|
|
# Position and Size Rules:
|
|
- Use local coordinates relative to component for elements.
|
|
- Don't provide position for elements inside flexible elements like `flex`, `grid`, `container`, etc.
|
|
- If children of `flex` and `grid` are not equally sized, provide `size` for children.
|
|
- Must provide `position` and `size` for elements inside `group` element.
|
|
|
|
# Chart Rules:
|
|
- Represent every chart using a single `chart` element.
|
|
- Chart coordinates are 1280x720 pixel units, never normalized 0-1 values.
|
|
- Every standalone chart must have an explicit local `position` and `size`; use the chart's visual bounds, or if adding a new chart with no source bounds, use `position: {"x": 0, "y": 0}` and a size that fills the chart component.
|
|
- Do not create tiny chart boxes. Explicit chart size must be at least 80px wide and 60px tall; prefer 640x300 or larger for primary charts.
|
|
- Detect charts by comparing the raw PPTX JSON with the reference slide image.
|
|
- When a chart is built from multiple raw elements, replace all elements that form the chart with one `chart` element.
|
|
- Chart-related parts such as legends, gridlines, axes, labels, and data series must be included within the `chart` element.
|
|
- If a line chart is represented using multiple `line` elements in the raw slide layout, remove those `line` elements and replace them with a single line `chart` element.
|
|
- If a chart legend is represented using separate `ellipse`, `shape`, or `text` elements, remove those elements. Do not recreate legends manually, because legends are included automatically by the `chart` element.
|
|
- If a chart is represented as an `image` element in the raw slide layout, convert that image into a `chart` element and remove the original `image` element.
|
|
- Always use a `chart` element for charts, even if the generated chart does not perfectly match the visual appearance of the reference slide image.
|
|
- Do not add standalone legends outside the `chart` element.
|
|
|
|
# Infographic Rules:
|
|
- Represent every infographic using a single `infographic` element.
|
|
- Detect infographic visuals by comparing the raw PPTX JSON with the reference slide image.
|
|
- When an infographic is built from multiple raw elements, replace all elements that form the infographic with one `infographic` element.
|
|
- If an infographic is represented as an `image` element in the raw slide layout, convert that image into an `infographic` element and remove the original `image` element.
|
|
|
|
# Schema Rules:
|
|
- Set `decorative=true` for elements that should stay fixed as part of the template design.
|
|
- Set `decorative=false` for content elements that should be replaced when creating a new slide from this layout.
|
|
- Try to keep `max_length`, `min_length`, `max_items` and `min_items` same as in the raw slide layout.
|
|
- If `flex` or `grid` contains list of same items, set the `max_length`, `min_length`, and other schema related constraints same for items.
|
|
- For same items arranged in `flex`/`grid` derive schema fields by averaging between those similar items.
|
|
|
|
# Preview Tool Rules:
|
|
- Must use `previewSlide` tool at least once to preview generated slide layout before returning final JSON.
|
|
- If no issues are identified in previewed slide image, return final json directly.
|
|
"""
|
|
|
|
CLUSTER_SIMILAR_COMPONENTS_SYSTEM_PROMPT = """
|
|
Analyze components `id` and `description` and create clusters of similar components.
|
|
|
|
# Steps:
|
|
1. Analyze components `id` and `description`.
|
|
2. Identify similar components.
|
|
3. Return cluster of similar components as output.
|
|
|
|
# Rules:
|
|
- Group components only when they serve the same semantic purpose and have substantially similar structure.
|
|
- Different content is expected and does not make otherwise equivalent components dissimilar.
|
|
- Do not group components merely because they share broad words such as title, text, image, or content.
|
|
- Each group must contain at least one index.
|
|
"""
|
|
|
|
|
|
def _ensure_unique_slide_layout_ids(layouts: list[SlideLayout]) -> list[SlideLayout]:
|
|
used_ids: set[str] = set()
|
|
unique_layouts: list[SlideLayout] = []
|
|
duplicate_count = 0
|
|
|
|
for index, layout in enumerate(layouts):
|
|
if layout.id not in used_ids:
|
|
used_ids.add(layout.id)
|
|
unique_layouts.append(layout)
|
|
continue
|
|
|
|
duplicate_count += 1
|
|
suffix = index + 1
|
|
candidate_id = f"{layout.id}_{suffix}"
|
|
while candidate_id in used_ids:
|
|
suffix += 1
|
|
candidate_id = f"{layout.id}_{suffix}"
|
|
used_ids.add(candidate_id)
|
|
unique_layouts.append(
|
|
layout.model_copy(deep=True, update={"id": candidate_id})
|
|
)
|
|
|
|
if duplicate_count:
|
|
LOGGER.warning(
|
|
"[templates.v2.generate] repaired duplicate slide layout ids count=%d",
|
|
duplicate_count,
|
|
)
|
|
|
|
return unique_layouts
|
|
|
|
|
|
def generate_template(
|
|
layouts: RawSlideLayouts,
|
|
slide_image_urls: list[str],
|
|
fonts: dict[str, str] | None = None,
|
|
) -> SlideLayouts:
|
|
"""Generate each template slide directly as a complete SlideLayout."""
|
|
if not layouts.layouts:
|
|
raise ValueError("layouts must contain at least one slide layout")
|
|
if len(slide_image_urls) != len(layouts.layouts):
|
|
raise ValueError("slide_image_urls must contain one image for each layout")
|
|
|
|
started_at = perf_counter()
|
|
slide_count = len(layouts.layouts)
|
|
max_workers = min(MAX_PARALLEL_SLIDE_LAYOUTS, slide_count)
|
|
LOGGER.info(
|
|
"[templates.v2.generate] direct slide layout generation start "
|
|
"slides=%d max_parallel=%d validation_retries=%d",
|
|
slide_count,
|
|
max_workers,
|
|
DEFAULT_VALIDATION_RETRIES,
|
|
)
|
|
|
|
layouts_by_index: dict[int, SlideLayout] = {}
|
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
futures = {
|
|
executor.submit(
|
|
generate_slide_layout,
|
|
layout,
|
|
index,
|
|
slide_image_urls[index],
|
|
fonts,
|
|
): index
|
|
for index, layout in enumerate(layouts.layouts)
|
|
}
|
|
for future in as_completed(futures):
|
|
index = futures[future]
|
|
layouts_by_index[index] = future.result()
|
|
LOGGER.info(
|
|
"[templates.v2.generate] slide layout complete slide=%d/%d "
|
|
"components=%d completed=%d/%d",
|
|
index + 1,
|
|
slide_count,
|
|
len(layouts_by_index[index].components),
|
|
len(layouts_by_index),
|
|
slide_count,
|
|
)
|
|
|
|
ordered_layouts = [layouts_by_index[index] for index in range(slide_count)]
|
|
generated = SlideLayouts(layouts=_ensure_unique_slide_layout_ids(ordered_layouts))
|
|
LOGGER.info(
|
|
"[templates.v2.generate] direct slide layout generation complete "
|
|
"slides=%d components=%d duration_ms=%.1f",
|
|
slide_count,
|
|
sum(len(layout.components) for layout in generated.layouts),
|
|
_elapsed_ms(started_at),
|
|
)
|
|
return generated
|
|
|
|
|
|
def merge_similar_components(layouts: SlideLayouts) -> MergedComponents:
|
|
indexed_components = [
|
|
component for layout in layouts.layouts for component in layout.components
|
|
]
|
|
if len(indexed_components) < 2:
|
|
return _build_merged_components(indexed_components, [])
|
|
|
|
component_summaries = [
|
|
{
|
|
"index": index,
|
|
"id": component.id,
|
|
"description": component.description,
|
|
}
|
|
for index, component in enumerate(indexed_components)
|
|
]
|
|
LOGGER.info(
|
|
"[templates.v2.deduplicate] clustering start components=%d",
|
|
len(indexed_components),
|
|
)
|
|
response = _generate_with_validation_retries(
|
|
client=get_client(config=get_llm_config()),
|
|
model=get_model(),
|
|
messages=[
|
|
SystemMessage(content=CLUSTER_SIMILAR_COMPONENTS_SYSTEM_PROMPT),
|
|
UserMessage(
|
|
content=json.dumps({"components": component_summaries}, indent=2)
|
|
),
|
|
],
|
|
label="similar component clusters",
|
|
output_model=SimilarComponentsList,
|
|
response_name="SimilarComponentsResponse",
|
|
validation_retries=DEFAULT_VALIDATION_RETRIES,
|
|
extra_validator=lambda clusters: _validate_similarity_groups(
|
|
clusters,
|
|
component_count=len(indexed_components),
|
|
),
|
|
max_tokens=16000,
|
|
)
|
|
clusters = SimilarComponentsList.model_validate(response)
|
|
merged = _build_merged_components(
|
|
indexed_components,
|
|
[group.indices for group in clusters.similar_components],
|
|
)
|
|
deduplicated = _deduplicate_merged_components(merged)
|
|
LOGGER.info(
|
|
"[templates.v2.deduplicate] clustering complete components=%d "
|
|
"similar_groups=%d merged_components=%d structural_duplicates=%d",
|
|
len(indexed_components),
|
|
len(clusters.similar_components),
|
|
len(deduplicated.components),
|
|
len(merged.components) - len(deduplicated.components),
|
|
)
|
|
return deduplicated
|
|
|
|
|
|
def _validate_similarity_groups(
|
|
clusters: SimilarComponentsList,
|
|
*,
|
|
component_count: int,
|
|
) -> None:
|
|
seen: set[int] = set()
|
|
for group in clusters.similar_components:
|
|
for index in group.indices:
|
|
if index >= component_count:
|
|
raise ValueError(
|
|
f"similar component index {index} is outside the available range"
|
|
)
|
|
if index in seen:
|
|
raise ValueError(
|
|
f"component index {index} appears in more than one similarity group"
|
|
)
|
|
seen.add(index)
|
|
|
|
|
|
def _build_merged_components(
|
|
components: list[Component],
|
|
similar_groups: list[list[int]],
|
|
) -> MergedComponents:
|
|
group_by_index = {
|
|
index: sorted(group) for group in similar_groups for index in group
|
|
}
|
|
used_indices: set[int] = set()
|
|
used_ids: set[str] = set()
|
|
merged_components: list[MergedComponent] = []
|
|
|
|
for index, component in enumerate(components):
|
|
if index in used_indices:
|
|
continue
|
|
variant_indices = group_by_index.get(index, [index])
|
|
variants = [components[variant_index] for variant_index in variant_indices]
|
|
used_indices.update(variant_indices)
|
|
merged_components.append(
|
|
MergedComponent(
|
|
id=_unique_merged_component_id(component.id, used_ids),
|
|
description=component.description,
|
|
variants=variants,
|
|
)
|
|
)
|
|
|
|
return MergedComponents(components=merged_components)
|
|
|
|
|
|
def _deduplicate_merged_components(merged: MergedComponents) -> MergedComponents:
|
|
if len(merged.components) < 2:
|
|
return merged
|
|
|
|
parent = list(range(len(merged.components)))
|
|
signature_owner: dict[tuple[Any, ...], int] = {}
|
|
|
|
def find(index: int) -> int:
|
|
while parent[index] != index:
|
|
parent[index] = parent[parent[index]]
|
|
index = parent[index]
|
|
return index
|
|
|
|
def union(first: int, second: int) -> None:
|
|
first_root = find(first)
|
|
second_root = find(second)
|
|
if first_root == second_root:
|
|
return
|
|
if first_root < second_root:
|
|
parent[second_root] = first_root
|
|
else:
|
|
parent[first_root] = second_root
|
|
|
|
for index, component_group in enumerate(merged.components):
|
|
for signature in _merged_component_variant_signatures(component_group):
|
|
previous_index = signature_owner.get(signature)
|
|
if previous_index is None:
|
|
signature_owner[signature] = index
|
|
continue
|
|
union(index, previous_index)
|
|
|
|
components_by_root: dict[int, list[int]] = {}
|
|
for index in range(len(merged.components)):
|
|
root = find(index)
|
|
components_by_root.setdefault(root, []).append(index)
|
|
|
|
deduplicated: list[MergedComponent] = []
|
|
emitted_roots: set[int] = set()
|
|
for index, component_group in enumerate(merged.components):
|
|
root = find(index)
|
|
if root in emitted_roots:
|
|
continue
|
|
emitted_roots.add(root)
|
|
duplicate_indices = components_by_root[root]
|
|
variants = [
|
|
variant
|
|
for duplicate_index in duplicate_indices
|
|
for variant in merged.components[duplicate_index].variants
|
|
]
|
|
deduplicated.append(
|
|
component_group.model_copy(deep=True, update={"variants": variants})
|
|
)
|
|
|
|
return MergedComponents(components=deduplicated)
|
|
|
|
|
|
def _merged_component_variant_signatures(
|
|
component_group: MergedComponent,
|
|
) -> tuple[tuple[Any, ...], ...]:
|
|
seen: set[tuple[Any, ...]] = set()
|
|
signatures: list[tuple[Any, ...]] = []
|
|
for variant in component_group.variants:
|
|
signature = _component_duplicate_signature(variant)
|
|
if signature in seen:
|
|
continue
|
|
seen.add(signature)
|
|
signatures.append(signature)
|
|
return tuple(signatures)
|
|
|
|
|
|
def _component_duplicate_signature(component: Component) -> tuple[Any, ...]:
|
|
component_data = component.model_dump(mode="json", exclude_none=True)
|
|
root_size = component_data.get("size")
|
|
return (
|
|
"component",
|
|
("aspect", _aspect_signature(root_size)),
|
|
(
|
|
"elements",
|
|
tuple(
|
|
_element_duplicate_signature(element, root_size=root_size)
|
|
for element in component_data.get("elements", [])
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def _element_duplicate_signature(
|
|
element: dict[str, Any],
|
|
*,
|
|
root_size: Any,
|
|
) -> tuple[Any, ...]:
|
|
element_type = str(element.get("type", ""))
|
|
decorative = bool(element.get("decorative", False))
|
|
items: list[tuple[str, Any]] = []
|
|
|
|
for key in sorted(element):
|
|
if key in _IGNORED_DUPLICATE_SCHEMA_KEYS:
|
|
continue
|
|
|
|
value = element[key]
|
|
if key == "position":
|
|
items.append((key, _position_signature(value, root_size)))
|
|
continue
|
|
if key == "size":
|
|
items.append((key, _size_signature(value, root_size)))
|
|
continue
|
|
if key == "child":
|
|
child_signature = (
|
|
_element_duplicate_signature(value, root_size=root_size)
|
|
if isinstance(value, dict)
|
|
else None
|
|
)
|
|
items.append((key, child_signature))
|
|
continue
|
|
if key == "children":
|
|
children = value if isinstance(value, list) else []
|
|
items.append(
|
|
(
|
|
key,
|
|
tuple(
|
|
_element_duplicate_signature(child, root_size=root_size)
|
|
for child in children
|
|
if isinstance(child, dict)
|
|
),
|
|
)
|
|
)
|
|
continue
|
|
if not decorative and key in _CONTENT_VALUE_KEYS_BY_ELEMENT_TYPE.get(
|
|
element_type, set()
|
|
):
|
|
continue
|
|
if not decorative and element_type == "table" and key in {"columns", "rows"}:
|
|
items.append((key, _normalize_signature_value(_strip_table_text(value))))
|
|
continue
|
|
|
|
items.append((key, _normalize_signature_value(value)))
|
|
|
|
return tuple(items)
|
|
|
|
|
|
def _strip_table_text(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return {
|
|
key: _strip_table_text(child)
|
|
for key, child in value.items()
|
|
if key != "runs"
|
|
}
|
|
if isinstance(value, list):
|
|
return [_strip_table_text(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _position_signature(value: Any, root_size: Any) -> tuple[Any, ...] | None:
|
|
if not isinstance(value, dict):
|
|
return None
|
|
return (
|
|
("x", _axis_signature(value.get("x"), root_size, "width")),
|
|
("y", _axis_signature(value.get("y"), root_size, "height")),
|
|
)
|
|
|
|
|
|
def _size_signature(value: Any, root_size: Any) -> tuple[Any, ...] | None:
|
|
if not isinstance(value, dict):
|
|
return None
|
|
return (
|
|
("width", _axis_signature(value.get("width"), root_size, "width")),
|
|
("height", _axis_signature(value.get("height"), root_size, "height")),
|
|
)
|
|
|
|
|
|
def _axis_signature(value: Any, root_size: Any, axis_key: str) -> Any:
|
|
number = _coerce_number(value)
|
|
if number is None:
|
|
return _normalize_signature_value(value)
|
|
|
|
axis_size = None
|
|
if isinstance(root_size, dict):
|
|
axis_size = _coerce_number(root_size.get(axis_key))
|
|
if axis_size is not None and axis_size > 0:
|
|
normalized = (number / axis_size) * 1000
|
|
return (
|
|
round(normalized / _DUPLICATE_POSITION_GRID_UNITS)
|
|
* _DUPLICATE_POSITION_GRID_UNITS
|
|
)
|
|
return round(number, 1)
|
|
|
|
|
|
def _aspect_signature(root_size: Any) -> Any:
|
|
if not isinstance(root_size, dict):
|
|
return None
|
|
width = _coerce_number(root_size.get("width"))
|
|
height = _coerce_number(root_size.get("height"))
|
|
if width is None or height is None or height <= 0:
|
|
return None
|
|
return round((width / height) * 100)
|
|
|
|
|
|
def _normalize_signature_value(value: Any) -> Any:
|
|
number = _coerce_number(value)
|
|
if number is not None:
|
|
return round(number, 2)
|
|
if isinstance(value, str):
|
|
return value.strip()
|
|
if isinstance(value, dict):
|
|
return tuple(
|
|
(key, _normalize_signature_value(child))
|
|
for key, child in sorted(value.items())
|
|
)
|
|
if isinstance(value, list):
|
|
return tuple(_normalize_signature_value(item) for item in value)
|
|
return value
|
|
|
|
|
|
def _coerce_number(value: Any) -> float | None:
|
|
if isinstance(value, bool):
|
|
return None
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
return None
|
|
|
|
|
|
def _unique_merged_component_id(component_id: str, used_ids: set[str]) -> str:
|
|
if component_id not in used_ids:
|
|
used_ids.add(component_id)
|
|
return component_id
|
|
|
|
suffix = 2
|
|
while True:
|
|
suffix_text = f"_{suffix}"
|
|
candidate = f"{component_id[: 80 - len(suffix_text)]}{suffix_text}"
|
|
if candidate not in used_ids:
|
|
used_ids.add(candidate)
|
|
return candidate
|
|
suffix += 1
|
|
|
|
|
|
def generate_slide_layout(
|
|
source_layout: RawSlideLayout,
|
|
slide_index: int,
|
|
slide_image_url: str,
|
|
fonts: dict[str, str] | None = None,
|
|
*,
|
|
max_tokens: int | None = None,
|
|
) -> SlideLayout:
|
|
payload = (
|
|
_strip_decorative_fields(
|
|
source_layout.model_dump(mode="json", exclude_none=True)
|
|
),
|
|
)
|
|
llm_config = get_llm_config()
|
|
client = get_client(config=llm_config)
|
|
model = get_model()
|
|
messages = [
|
|
SystemMessage(content=GENERATE_SLIDE_LAYOUT_SYSTEM_PROMPT),
|
|
UserMessage(
|
|
content=[
|
|
_slide_image_content(slide_image_url),
|
|
json.dumps(payload, indent=2),
|
|
]
|
|
),
|
|
]
|
|
preview_tool = PreviewSlideTool(slide_index=slide_index, fonts=fonts)
|
|
layout = _generate_preview_candidate(
|
|
client=client,
|
|
model=model,
|
|
messages=messages,
|
|
label=f"slide {slide_index + 1}",
|
|
preview_tool=preview_tool,
|
|
validation_retries=DEFAULT_VALIDATION_RETRIES,
|
|
max_tokens=max_tokens,
|
|
)
|
|
return _replace_content_image_urls(layout)
|
|
|
|
|
|
def _replace_content_image_urls(layout: SlideLayout) -> SlideLayout:
|
|
normalized = layout.model_copy(deep=True)
|
|
for component in normalized.components:
|
|
_replace_content_image_urls_in_elements(component.elements)
|
|
return normalized
|
|
|
|
|
|
def _replace_content_image_urls_in_elements(elements: list[Any]) -> None:
|
|
for element in elements:
|
|
_replace_content_image_url_in_element(element)
|
|
|
|
|
|
def _replace_content_image_url_in_element(element: Any) -> None:
|
|
if isinstance(element, SlideImageElement) and element.decorative is False:
|
|
element.data = (
|
|
CONTENT_ICON_PLACEHOLDER_URL
|
|
if element.is_icon
|
|
else CONTENT_IMAGE_PLACEHOLDER_URL
|
|
)
|
|
|
|
child = getattr(element, "child", None)
|
|
if child is not None:
|
|
_replace_content_image_url_in_element(child)
|
|
|
|
children = getattr(element, "children", None)
|
|
if isinstance(children, list):
|
|
_replace_content_image_urls_in_elements(children)
|
|
|
|
|
|
def _strip_decorative_fields(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return {
|
|
key: _strip_decorative_fields(child)
|
|
for key, child in value.items()
|
|
if key != "decorative"
|
|
}
|
|
if isinstance(value, list):
|
|
return [_strip_decorative_fields(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _generate_preview_candidate(
|
|
*,
|
|
client: Any,
|
|
model: str,
|
|
messages: list[Any],
|
|
label: str,
|
|
preview_tool: PreviewSlideTool,
|
|
validation_retries: int,
|
|
max_tokens: int | None = None,
|
|
) -> SlideLayout:
|
|
attempt_messages = list(messages)
|
|
last_error: Exception | None = None
|
|
max_attempts = validation_retries + 1
|
|
preview_call_count = 0
|
|
|
|
for attempt in range(1, max_attempts + 1):
|
|
attempt_started_at = perf_counter()
|
|
preview_tool_available = preview_call_count < MAX_PREVIEW_SLIDE_CALLS and (
|
|
attempt <= validation_retries or preview_call_count == 0
|
|
)
|
|
LOGGER.info(
|
|
"[templates.v2.llm] %s: requesting slide layout attempt=%d/%d model=%s",
|
|
label,
|
|
attempt,
|
|
max_attempts,
|
|
model,
|
|
)
|
|
try:
|
|
generate_kwargs = {
|
|
"model": model,
|
|
"messages": attempt_messages,
|
|
"response_format": JSONSchemaResponse(
|
|
name="SlideLayoutResponse",
|
|
strict=False,
|
|
json_schema=SlideLayout,
|
|
),
|
|
}
|
|
if max_tokens is not None:
|
|
generate_kwargs["max_tokens"] = max_tokens
|
|
if preview_tool_available:
|
|
generate_kwargs.update(
|
|
{
|
|
"tools": [preview_tool],
|
|
"tool_choice": ToolChoice(
|
|
mode=ToolChoiceMode.AUTO,
|
|
tools=[PREVIEW_SLIDE_TOOL_NAME],
|
|
),
|
|
}
|
|
)
|
|
response = client.generate(**generate_kwargs)
|
|
tool_call = None
|
|
if preview_tool_available:
|
|
tool_call = next(
|
|
(
|
|
call
|
|
for call in list(getattr(response, "tool_calls", []) or [])
|
|
if call.name == preview_tool.name
|
|
),
|
|
None,
|
|
)
|
|
if tool_call is None:
|
|
parsed = _parse_json_content(response.content)
|
|
layout = SlideLayout.model_validate(parsed)
|
|
LOGGER.info(
|
|
"[templates.v2.llm] %s: slide layout JSON returned "
|
|
"attempt=%d/%d duration_ms=%.1f components=%d",
|
|
label,
|
|
attempt,
|
|
max_attempts,
|
|
_elapsed_ms(attempt_started_at),
|
|
len(layout.components),
|
|
)
|
|
return layout
|
|
|
|
arguments = json.loads(tool_call.arguments or "{}")
|
|
if not isinstance(arguments, dict):
|
|
raise ValueError(f"{preview_tool.name} arguments must be a JSON object")
|
|
candidate_layout = SlideLayout.model_validate(arguments)
|
|
preview_call_count += 1
|
|
LOGGER.info(
|
|
"[templates.v2.llm] %s: preview slide called attempt=%d/%d "
|
|
"preview_call=%d components=%d",
|
|
label,
|
|
attempt,
|
|
max_attempts,
|
|
preview_call_count,
|
|
len(candidate_layout.components),
|
|
)
|
|
LOGGER.info(
|
|
"[templates.v2.llm] %s: rendering preview slide attempt=%d/%d",
|
|
label,
|
|
attempt,
|
|
max_attempts,
|
|
)
|
|
preview_image = preview_tool.render(candidate_layout)
|
|
LOGGER.info(
|
|
"[templates.v2.llm] %s: preview slide rendered attempt=%d/%d "
|
|
"duration_ms=%.1f",
|
|
label,
|
|
attempt,
|
|
max_attempts,
|
|
_elapsed_ms(attempt_started_at),
|
|
)
|
|
if attempt > validation_retries:
|
|
LOGGER.info(
|
|
"[templates.v2.llm] %s: returning preview slide JSON as final "
|
|
"attempt=%d/%d preview_call=%d duration_ms=%.1f components=%d",
|
|
label,
|
|
attempt,
|
|
max_attempts,
|
|
preview_call_count,
|
|
_elapsed_ms(attempt_started_at),
|
|
len(candidate_layout.components),
|
|
)
|
|
return candidate_layout
|
|
|
|
response_messages = list(getattr(response, "messages", []) or [])
|
|
if response_messages:
|
|
history_messages = response_messages
|
|
else:
|
|
response_text = _text_from_content(getattr(response, "content", None))
|
|
assistant_message = AssistantMessage(
|
|
content=[response_text] if response_text else None,
|
|
tool_calls=[tool_call],
|
|
)
|
|
history_messages = [*attempt_messages, assistant_message]
|
|
|
|
attempt_messages = [
|
|
*history_messages,
|
|
ToolResponseMessage(
|
|
id=tool_call.id,
|
|
content=["The slide preview was rendered successfully."],
|
|
),
|
|
UserMessage(
|
|
content=[
|
|
preview_image,
|
|
_preview_feedback_instruction(preview_call_count),
|
|
]
|
|
),
|
|
]
|
|
LOGGER.info(
|
|
"[templates.v2.llm] %s: asking LLM to review rendered preview "
|
|
"attempt=%d/%d",
|
|
label,
|
|
attempt,
|
|
max_attempts,
|
|
)
|
|
except (JSONDecodeError, ValidationError, ValueError) as exc:
|
|
last_error = exc
|
|
LOGGER.warning(
|
|
"[templates.v2.llm] %s: invalid slide layout response "
|
|
"attempt=%d/%d duration_ms=%.1f error=%s",
|
|
label,
|
|
attempt,
|
|
max_attempts,
|
|
_elapsed_ms(attempt_started_at),
|
|
exc,
|
|
)
|
|
if attempt > validation_retries:
|
|
raise
|
|
retry_instruction = (
|
|
f"Return one complete SlideLayout JSON object, or call "
|
|
f"{preview_tool.name} with one complete SlideLayout JSON object."
|
|
if preview_call_count < MAX_PREVIEW_SLIDE_CALLS
|
|
else "Return one complete SlideLayout JSON object without calling a tool."
|
|
)
|
|
attempt_messages = [
|
|
*attempt_messages,
|
|
UserMessage(
|
|
content=(
|
|
f"The previous response for {label} was invalid. "
|
|
f"{retry_instruction}\n\n"
|
|
f"errors:\n{_format_error_for_prompt(exc)}"
|
|
)
|
|
),
|
|
]
|
|
except Exception as exc:
|
|
last_error = exc
|
|
LOGGER.warning(
|
|
"[templates.v2.llm] %s: preview slide flow failed "
|
|
"attempt=%d/%d duration_ms=%.1f error=%s",
|
|
label,
|
|
attempt,
|
|
max_attempts,
|
|
_elapsed_ms(attempt_started_at),
|
|
exc,
|
|
)
|
|
if attempt > validation_retries:
|
|
raise
|
|
retry_instruction = (
|
|
"Call the tool again with the complete candidate SlideLayout."
|
|
if preview_call_count < MAX_PREVIEW_SLIDE_CALLS
|
|
else "Return one complete SlideLayout JSON object without calling a tool."
|
|
)
|
|
attempt_messages = [
|
|
*attempt_messages,
|
|
UserMessage(
|
|
content=(
|
|
f"The {preview_tool.name} call for {label} failed. "
|
|
f"{retry_instruction}\n\n"
|
|
f"errors:\n{_format_error_for_prompt(exc)}"
|
|
)
|
|
),
|
|
]
|
|
|
|
if last_error is not None:
|
|
raise last_error
|
|
raise RuntimeError(f"LLM failed to produce a preview candidate for {label}")
|
|
|
|
|
|
def _preview_feedback_instruction(preview_call_count: int) -> str:
|
|
base = (
|
|
"Review this rendered candidate against the original slide image. "
|
|
"Fix visual problems such as incorrect grouping, alignment, sizing, "
|
|
"overflow, spacing, colors, and local coordinates. "
|
|
)
|
|
if preview_call_count >= MAX_PREVIEW_SLIDE_CALLS:
|
|
return (
|
|
base + "You have used the maximum number of previewSlide calls. "
|
|
"Return the complete final SlideLayout JSON without calling previewSlide again, "
|
|
"even when no changes are needed."
|
|
)
|
|
return (
|
|
base
|
|
+ "Return the complete final SlideLayout JSON, or call previewSlide one more time "
|
|
"only if another visual check is needed."
|
|
)
|
|
|
|
|
|
def _slide_image_content(slide_image_url: str) -> ImageContentPart:
|
|
image_path = resolve_image_path_to_filesystem(slide_image_url)
|
|
if image_path:
|
|
with open(image_path, "rb") as image_file:
|
|
image_bytes = image_file.read()
|
|
mime_type = mimetypes.guess_type(image_path)[0] or "image/png"
|
|
return ImageContentPart(data=image_bytes, mime_type=mime_type)
|
|
|
|
return ImageContentPart(url=slide_image_url)
|
|
|
|
|
|
def _generate_with_validation_retries(
|
|
*,
|
|
client: Any,
|
|
model: str,
|
|
messages: list[Any],
|
|
label: str,
|
|
output_model: type[BaseModel],
|
|
response_name: str,
|
|
validation_retries: int,
|
|
extra_validator: Callable[[Any], None] | None = None,
|
|
max_tokens: int = 8192,
|
|
) -> dict[str, Any]:
|
|
attempt_messages = list(messages)
|
|
last_error: Exception | None = None
|
|
max_attempts = validation_retries + 1
|
|
|
|
for attempt in range(1, max_attempts + 1):
|
|
attempt_started_at = perf_counter()
|
|
LOGGER.info(
|
|
"[templates.v2.llm] request start label=%s model=%s attempt=%d/%d "
|
|
"retry=%d/%d messages=%d",
|
|
label,
|
|
model,
|
|
attempt,
|
|
max_attempts,
|
|
attempt - 1,
|
|
validation_retries,
|
|
len(attempt_messages),
|
|
)
|
|
try:
|
|
response = client.generate(
|
|
model=model,
|
|
messages=attempt_messages,
|
|
response_format=JSONSchemaResponse(
|
|
name=response_name,
|
|
strict=False,
|
|
json_schema=output_model,
|
|
),
|
|
max_tokens=max_tokens,
|
|
)
|
|
except Exception as exc:
|
|
last_error = exc
|
|
LOGGER.warning(
|
|
"[templates.v2.llm] request failed label=%s model=%s "
|
|
"attempt=%d/%d duration_ms=%.1f error=%s",
|
|
label,
|
|
model,
|
|
attempt,
|
|
max_attempts,
|
|
_elapsed_ms(attempt_started_at),
|
|
exc,
|
|
)
|
|
if attempt > validation_retries:
|
|
raise
|
|
attempt_messages = _messages_for_generation_error_retry(
|
|
messages=attempt_messages,
|
|
label=label,
|
|
error=exc,
|
|
)
|
|
continue
|
|
|
|
try:
|
|
parsed = _parse_json_content(response.content)
|
|
validated = _validate_output_model(
|
|
parsed,
|
|
output_model,
|
|
extra_validator=extra_validator,
|
|
)
|
|
LOGGER.info(
|
|
"[templates.v2.llm] response validated label=%s model=%s "
|
|
"attempt=%d/%d duration_ms=%.1f schema=%s",
|
|
label,
|
|
model,
|
|
attempt,
|
|
max_attempts,
|
|
_elapsed_ms(attempt_started_at),
|
|
response_name,
|
|
)
|
|
return validated
|
|
except ValidationError as exc:
|
|
last_error = exc
|
|
if attempt > validation_retries:
|
|
raise
|
|
attempt_messages = _messages_for_model_validation_retry(
|
|
messages=attempt_messages,
|
|
response=response,
|
|
label=label,
|
|
output_model=output_model,
|
|
error=exc,
|
|
invalid_response=parsed,
|
|
)
|
|
except (JSONDecodeError, ValueError) as exc:
|
|
last_error = exc
|
|
if attempt > validation_retries:
|
|
raise
|
|
attempt_messages = _messages_for_json_repair_retry(
|
|
messages=attempt_messages,
|
|
response=response,
|
|
label=label,
|
|
error=exc,
|
|
)
|
|
|
|
if last_error is not None:
|
|
raise last_error
|
|
raise RuntimeError(f"LLM failed to generate {label}")
|
|
|
|
|
|
def _validate_output_model(
|
|
parsed: dict[str, Any],
|
|
output_model: type[BaseModel],
|
|
*,
|
|
extra_validator: Callable[[Any], None] | None = None,
|
|
) -> dict[str, Any]:
|
|
validated = output_model.model_validate(parsed)
|
|
if extra_validator is not None:
|
|
extra_validator(validated)
|
|
return validated.model_dump(mode="json")
|
|
|
|
|
|
def _parse_json_content(content: Any) -> dict[str, Any]:
|
|
text_content = _text_from_content(content)
|
|
parsed = json.loads(text_content) if text_content is not None else content
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError("LLM response must be a JSON object")
|
|
return parsed
|
|
|
|
|
|
def _text_from_content(content: Any) -> str | None:
|
|
if isinstance(content, str):
|
|
return content
|
|
if not isinstance(content, list):
|
|
return None
|
|
|
|
parts: list[str] = []
|
|
for part in content:
|
|
if isinstance(part, str):
|
|
parts.append(part)
|
|
continue
|
|
text = getattr(part, "text", None)
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
return "".join(parts) if parts else None
|
|
|
|
|
|
def _messages_for_generation_error_retry(
|
|
*,
|
|
messages: list[Any],
|
|
label: str,
|
|
error: Exception,
|
|
) -> list[Any]:
|
|
return [
|
|
*messages,
|
|
UserMessage(
|
|
content=_json_repair_prompt(
|
|
label=label,
|
|
invalid_response=None,
|
|
error=error,
|
|
)
|
|
),
|
|
]
|
|
|
|
|
|
def _messages_for_json_repair_retry(
|
|
*,
|
|
messages: list[Any],
|
|
response: Any,
|
|
label: str,
|
|
error: Exception,
|
|
) -> list[Any]:
|
|
invalid_response = _text_from_content(response.content) or response.content
|
|
return [
|
|
*messages,
|
|
AssistantMessage(content=[_json_dumps_for_prompt(invalid_response)]),
|
|
UserMessage(
|
|
content=_json_repair_prompt(
|
|
label=label,
|
|
invalid_response=invalid_response,
|
|
error=error,
|
|
)
|
|
),
|
|
]
|
|
|
|
|
|
def _messages_for_model_validation_retry(
|
|
*,
|
|
messages: list[Any],
|
|
response: Any,
|
|
label: str,
|
|
output_model: type[BaseModel],
|
|
error: ValidationError,
|
|
invalid_response: dict[str, Any],
|
|
) -> list[Any]:
|
|
return [
|
|
*messages,
|
|
AssistantMessage(content=[_json_dumps_for_prompt(invalid_response)]),
|
|
UserMessage(
|
|
content=_model_validation_repair_prompt(
|
|
label=label,
|
|
output_model=output_model,
|
|
invalid_response=invalid_response,
|
|
error=error,
|
|
)
|
|
),
|
|
]
|
|
|
|
|
|
def _json_repair_prompt(
|
|
*,
|
|
label: str,
|
|
invalid_response: Any | None,
|
|
error: Exception,
|
|
) -> str:
|
|
parts = [
|
|
f"The previous {label} response was not valid for this task.",
|
|
"Return a complete replacement JSON object.",
|
|
"Return raw JSON only. Do not include markdown fences, comments, explanations, or text outside the JSON object.",
|
|
"",
|
|
"errors:",
|
|
_format_error_for_prompt(error),
|
|
]
|
|
if invalid_response is not None:
|
|
parts.extend(
|
|
["", "invalid_response:", _json_dumps_for_prompt(invalid_response)]
|
|
)
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _model_validation_repair_prompt(
|
|
*,
|
|
label: str,
|
|
output_model: type[BaseModel],
|
|
invalid_response: dict[str, Any],
|
|
error: ValidationError,
|
|
) -> str:
|
|
return "\n".join(
|
|
[
|
|
f"The previous {label} JSON did not match the required schema.",
|
|
"Return a complete corrected replacement JSON object.",
|
|
"For a SlideLayout, return id, description, and the complete components list in the same response.",
|
|
"Each component must include position, size, and local-coordinate elements.",
|
|
"Return raw JSON only. Do not include markdown fences, comments, explanations, or text outside the JSON object.",
|
|
"",
|
|
"validation_errors:",
|
|
_format_error_for_prompt(error),
|
|
"",
|
|
"invalid_response:",
|
|
_json_dumps_for_prompt(invalid_response),
|
|
"",
|
|
"required_json_schema:",
|
|
_json_dumps_for_prompt(output_model.model_json_schema()),
|
|
]
|
|
)
|
|
|
|
|
|
def _format_error_for_prompt(error: Exception) -> str:
|
|
if isinstance(error, ValidationError):
|
|
return _json_dumps_for_prompt(error.errors(include_input=False))
|
|
if isinstance(error, JSONDecodeError):
|
|
return _json_dumps_for_prompt([{"type": "JSONDecodeError", "msg": str(error)}])
|
|
return _json_dumps_for_prompt([{"type": type(error).__name__, "msg": str(error)}])
|
|
|
|
|
|
def _json_dumps_for_prompt(value: Any) -> str:
|
|
return json.dumps(value, indent=2, ensure_ascii=False, default=str)
|
|
|
|
|
|
def _elapsed_ms(started_at: float) -> float:
|
|
return (perf_counter() - started_at) * 1000
|