Files
2026-07-13 13:08:41 +08:00

1665 lines
63 KiB
Python

import json
import logging
import re
from typing import Any, Awaitable, Callable, Literal
import dirtyjson # type: ignore[import-untyped]
from llmai.shared import AssistantToolCall, Tool # type: ignore[import-not-found]
from constants.presentation import MAX_NUMBER_OF_SLIDES, MAX_OUTLINE_CONTENT_WORDS
from services.chat.schemas import (
AddElementInput,
AddNewSlideInput,
AddNewSlideLayoutInput,
AddOutlineInput,
AddSlideComponentInput,
DeleteSlideComponentInput,
DeleteSlideElementInput,
DeleteSlideInput,
DeleteOutlineInput,
GenerateAssetsInput,
GetAvailableBlocksInput,
GetContentSchemaFromLayoutIdInput,
GetSlideAtIndexInput,
NoArgsInput,
ReadSourceDocumentsInput,
SaveSlideInput,
SearchSlidesInput,
SetPresentationThemeInput,
UpdateComponentInput,
UpdateSlideInput,
UpdateSlideComponentInput,
UpdateOutlineInput,
UpdateSlideElementInput,
)
from services.chat.presentation_context_store import PresentationContextStore
LOGGER = logging.getLogger(__name__)
ToolHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
ChatToolMode = Literal["presentation", "outline"]
CHART_INSERT_TOOL_FIELDS = {
"addElement": "element",
"addComponent": "component",
"createComponent": "component",
"updateComponent": "component",
}
TABLE_INSERT_TOOL_FIELDS = CHART_INSERT_TOOL_FIELDS
IMAGE_INSERT_TOOL_FIELDS = CHART_INSERT_TOOL_FIELDS
BLOCK_PRIORITIZED_INSERT_TYPES = {"chart", "table"}
JSON_OBJECT_STRING_FIELDS = {
"addNewSlideLayout": ("content",),
"saveSlide": ("content",),
"updateSlide": ("content",),
"addElement": ("element",),
"updateElement": ("element",),
"addComponent": ("component",),
"createComponent": ("component",),
"updateComponent": ("component",),
}
MAX_TOOL_REPAIR_RETRIES = 1
def _normalize_tool_argument_value(value: Any) -> Any:
if isinstance(value, str) and value.strip().lower() == "null":
return None
if isinstance(value, list):
return [_normalize_tool_argument_value(item) for item in value]
if isinstance(value, dict):
return {
key: _normalize_tool_argument_value(item)
for key, item in value.items()
}
return value
class ChatTools:
def __init__(
self,
memory: PresentationContextStore,
mode: ChatToolMode = "presentation",
):
self._memory = memory
self._mode = mode
self._turn_user_message = ""
self._generated_assets: list[dict[str, Any]] = []
self._tool_handlers: dict[str, ToolHandler] = {
"addOutline": self._add_outline,
"updateOutline": self._update_outline,
"deleteOutline": self._delete_outline,
"addNewSlide": self._add_new_slide,
"addNewSlideLayout": self._add_new_slide_layout,
"getTemplateSummary": self._get_template_summary,
"readSourceDocuments": self._read_source_documents,
"searchSlide": self._search_slides,
"getSlideAtIndex": self._get_slide_at_index,
"getAvailableLayouts": self._get_available_layouts,
"getAvailableBlocks": self._get_available_blocks,
"getContentSchemaFromLayoutId": self._get_content_schema_from_layout_id,
"generateAssets": self._generate_assets,
"saveSlide": self._save_slide,
"updateSlide": self._update_slide,
"deleteSlide": self._delete_slide,
"addElement": self._add_element,
"updateElement": self._update_slide_element,
"deleteElement": self._delete_slide_element,
"addComponent": self._add_slide_component,
"createComponent": self._add_slide_component,
"updateComponent": self._update_component,
"deleteComponent": self._delete_slide_component,
"getPresentationTheme": self._get_presentation_theme_catalog,
"setPresentationTheme": self._set_presentation_theme,
}
def set_turn_context(self, user_message: str) -> None:
self._turn_user_message = user_message or ""
self._generated_assets = []
def get_tool_definitions(self) -> list[Tool]:
return [
Tool(
name="addOutline",
description=(
"Insert a new markdown outline item into the outline draft. "
"This edits presentation.outlines only and does not require a layout. "
f"Do not exceed {MAX_NUMBER_OF_SLIDES} outline slides or "
f"{MAX_OUTLINE_CONTENT_WORDS} words in this outline item."
),
schema=AddOutlineInput,
strict=False,
),
Tool(
name="updateOutline",
description=(
"Replace the markdown content of one outline item by zero-based index. "
"This edits presentation.outlines only and does not require a layout. "
f"Keep this outline item within {MAX_OUTLINE_CONTENT_WORDS} words."
),
schema=UpdateOutlineInput,
strict=False,
),
Tool(
name="deleteOutline",
description=(
"Delete one outline item by zero-based index. This edits "
"presentation.outlines only and does not require a layout."
),
schema=DeleteOutlineInput,
strict=False,
),
Tool(
name="addNewSlide",
description=(
"Add a blank slide to the current presentation at a zero-based index "
"or append when index is null."
),
schema=AddNewSlideInput,
strict=False,
),
Tool(
name="addNewSlideLayout",
description=(
"Add a new slide from an available layout. Use getAvailableLayouts "
"first, then pass content as a JSON-serialized object matching the layout."
),
schema=AddNewSlideLayoutInput,
strict=False,
),
Tool(
name="getAvailableLayouts",
description="List available slide layout ids, names, and summaries.",
schema=NoArgsInput,
strict=False,
),
Tool(
name="getAvailableBlocks",
description=(
"Search reusable template component blocks without fetching a whole layout. "
"Use this before addComponent/createComponent when the user asks to add "
"a styled block such as a title/header/subtitle, table, chart, card, "
"callout, metric, image panel, or repeated content item. For title "
"blocks use elementType=text with title/header/heading query terms. "
"Set includeFullContent=true only when exact component JSON is needed."
),
schema=GetAvailableBlocksInput,
strict=False,
),
Tool(
name="getContentSchemaFromLayoutId",
description=(
"Return the exact JSON content schema for one layout id. "
"Use this before addNewSlideLayout or updateSlide when composing "
"a full slide content payload."
),
schema=GetContentSchemaFromLayoutIdInput,
strict=False,
),
Tool(
name="getTemplateSummary",
description=(
"Read a compact summary of the current presentation template, "
"layouts, current slides, and theme. Use before choosing where/how to edit."
),
schema=NoArgsInput,
strict=False,
),
Tool(
name="readSourceDocuments",
description=(
"Read parsed text from the source document(s) uploaded for this "
"presentation, including PDFs. Use when the user refers to an "
"uploaded/source PDF, document, file, or the document used to "
"generate the deck. Use before summarizing, quoting, extracting "
"data, or creating slide content from uploaded documents."
),
schema=ReadSourceDocumentsInput,
strict=False,
),
Tool(
name="searchSlide",
description=(
"Search current slides for text/topics and return slide indices and snippets."
),
schema=SearchSlidesInput,
strict=False,
),
Tool(
name="getSlideAtIndex",
description=(
"Live SQL: one slide by index—authoritative for exact current content. "
"Set includeFullContent=true when you need full JSON (before saveSlide or precise edits). "
"If user says slide N, use zero-based index N-1."
),
schema=GetSlideAtIndexInput,
strict=False,
),
Tool(
name="saveSlide",
description=(
"Save full slide content for a layout. Use for complete slide payloads; "
"visible element/component edits should use element/component tools."
),
schema=SaveSlideInput,
strict=False,
),
Tool(
name="updateSlide",
description="Replace an existing slide's layout/content by zero-based index.",
schema=UpdateSlideInput,
strict=False,
),
Tool(
name="deleteSlide",
description="Delete an existing slide by zero-based index and reindex the rest.",
schema=DeleteSlideInput,
strict=False,
),
Tool(
name="addElement",
description=(
"Add one rendered UI element to a slide, either inside a componentId "
"or as a new free component when componentId is null. Do not use this "
"for new table/chart requests when a reusable block exists; use "
"getAvailableBlocks and addComponent/createComponent instead. Chart "
"elements must include numeric data as categories plus series.values, "
"or legacy data rows with label/value. Image elements must include "
"data set to a URL returned by generateAssets."
),
schema=AddElementInput,
strict=False,
),
Tool(
name="updateElement",
description=(
"Update visible element content or geometry using an elementPath returned "
"by getSlideAtIndex. Supports text, lists, table, chart, image data, "
"position, size, and toolbar-style font, color, fill, stroke, "
"alignment, opacity, and element property patches. For "
"charts, use the chart field for chartType, categories, "
"series.values, colors, axes, dataLabels placement, and legend."
),
schema=UpdateSlideElementInput,
strict=False,
),
Tool(
name="deleteElement",
description="Delete one rendered UI element by elementPath.",
schema=DeleteSlideElementInput,
strict=False,
),
Tool(
name="addComponent",
description=(
"Add an existing/new rendered UI component block to a slide. Component "
"JSON must include id, description, position, size, and elements. Chart "
"elements must include numeric data as categories plus series.values, or "
"legacy data rows with label/value. Image elements must include data "
"set to a URL returned by generateAssets. For styled title/header, "
"table/chart, card, metric, or callout additions, adapt a component "
"returned by getAvailableBlocks and pass sourceBlockId."
),
schema=AddSlideComponentInput,
strict=False,
),
Tool(
name="createComponent",
description=(
"Create a grouped rendered UI component from provided component JSON "
"and add it to a slide. Chart elements must include numeric data as "
"categories plus series.values, or legacy data rows with label/value. "
"Image elements must include data set to a URL returned by generateAssets. "
"For styled title/header, table/chart, card, metric, or callout additions "
"adapt a component returned by getAvailableBlocks and pass sourceBlockId."
),
schema=AddSlideComponentInput,
strict=False,
),
Tool(
name="updateComponent",
description=(
"Move, resize, replace, duplicate, reorder, group, or ungroup rendered "
"UI components by componentId."
),
schema=UpdateComponentInput,
strict=False,
),
Tool(
name="deleteComponent",
description=(
"Remove one whole component (a block such as a numbered point, card, "
"or callout) from a rendered slide by componentId."
),
schema=DeleteSlideComponentInput,
strict=False,
),
Tool(
name="getPresentationTheme",
description="Read the current presentation theme and available themes.",
schema=NoArgsInput,
strict=False,
),
Tool(
name="setPresentationTheme",
description=(
"Change the deck theme by theme name/id/query or customTheme payload."
),
schema=SetPresentationThemeInput,
strict=False,
),
Tool(
name="generateAssets",
description="Generate one or more image/icon assets for slide edits.",
schema=GenerateAssetsInput,
strict=False,
),
]
async def execute_tool_call(self, tool_call: AssistantToolCall) -> dict[str, Any]:
handler = self._tool_handlers.get(tool_call.name)
if not handler:
return {
"ok": False,
"tool": tool_call.name,
"error": f"Unsupported tool: {tool_call.name}",
"recovery": {
"retryable": False,
"message": "Use one of the available chat tools.",
"guidance": ["Choose a tool from the tool definitions."],
},
}
parsed_args: dict[str, Any] | None = None
repair_notes: list[str] = []
try:
parsed_args = self._parse_args(tool_call.arguments)
parsed_args, repair_notes = self._repair_tool_args(
tool_call.name,
parsed_args,
)
LOGGER.info("Executing chat tool %s", tool_call.name)
try:
result = await handler(parsed_args)
except Exception as first_exc:
retried = False
for _attempt in range(MAX_TOOL_REPAIR_RETRIES):
retry_args, retry_notes = self._repair_tool_args(
tool_call.name,
parsed_args,
error=str(first_exc),
)
if not retry_notes or self._args_equivalent(parsed_args, retry_args):
break
repair_notes.extend(retry_notes)
parsed_args = retry_args
LOGGER.info(
"Retrying chat tool %s after argument repair",
tool_call.name,
)
result = await handler(parsed_args)
retried = True
break
if not retried:
raise first_exc
if tool_call.name == "generateAssets":
self._remember_generated_assets(result)
response = {"ok": True, "tool": tool_call.name, "result": result}
if repair_notes:
response["repair"] = {
"applied": True,
"notes": repair_notes,
}
return response
except Exception as exc:
LOGGER.exception("Chat tool failed: %s", tool_call.name)
return {
"ok": False,
"tool": tool_call.name,
"error": str(exc),
"repair": {
"attempted": bool(repair_notes),
"notes": repair_notes,
},
"recovery": self._build_tool_recovery(
tool_name=tool_call.name,
args=parsed_args,
error=str(exc),
),
}
async def _get_presentation_outline(self, _: dict[str, Any]) -> dict[str, Any]:
outline = await self._memory.get("presentation_outline")
if not isinstance(outline, dict):
return {
"found": False,
"message": "Presentation outline is not available in memory yet.",
"sections": [],
}
slides = outline.get("slides")
if not isinstance(slides, list) or not slides:
return {
"found": False,
"message": "Presentation outline exists but has no slides.",
"sections": [],
}
sections: list[dict[str, Any]] = []
for position, slide in enumerate(slides):
index = position
content = ""
if isinstance(slide, dict):
raw_index = slide.get("index")
if isinstance(raw_index, int):
index = raw_index
raw_content = slide.get("content")
if isinstance(raw_content, str):
content = raw_content
elif raw_content is not None:
try:
content = json.dumps(raw_content, ensure_ascii=False)
except Exception:
content = str(raw_content)
elif isinstance(slide, str):
content = slide
title = self._extract_title(content) or f"Slide {index + 1}"
sections.append(
{
"index": index,
"slide_number": index + 1,
"title": title,
}
)
return {
"found": True,
"slide_count": len(sections),
"sections": sections,
"source": outline.get("source", "memory"),
}
async def _search_slides(self, args: dict[str, Any]) -> dict[str, Any]:
payload = SearchSlidesInput(**args)
results = await self._memory.search(payload.query, payload.limit)
return {
"query": payload.query,
"count": len(results),
"results": results,
}
async def _get_slide_at_index(self, args: dict[str, Any]) -> dict[str, Any]:
normalized_args = dict(args)
normalized_args.setdefault("includeFullContent", False)
payload = GetSlideAtIndexInput(**normalized_args)
slide = await self._memory.get_slide_at_index(
payload.index,
include_full_content=payload.include_full_content,
)
if not slide and payload.index > 0:
# Users often refer to slides as 1-based; allow a safe fallback.
fallback_index = payload.index - 1
fallback_slide = await self._memory.get_slide_at_index(
fallback_index,
include_full_content=payload.include_full_content,
)
if fallback_slide:
return {
"found": True,
"slide": fallback_slide,
"requested_index": payload.index,
"resolved_index": fallback_index,
"note": (
"No slide found at requested index; returned one-based fallback "
f"at index {fallback_index}."
),
}
if not slide:
return {
"found": False,
"message": f"No slide found at index {payload.index}.",
}
return {
"found": True,
"slide": slide,
}
async def _get_outline_draft(self, _: dict[str, Any]) -> dict[str, Any]:
return await self._memory.get_outline_draft()
async def _add_outline(self, args: dict[str, Any]) -> dict[str, Any]:
payload = AddOutlineInput(**args)
return await self._memory.add_outline(
content=payload.content,
index=payload.index,
)
async def _update_outline(self, args: dict[str, Any]) -> dict[str, Any]:
payload = UpdateOutlineInput(**args)
return await self._memory.update_outline(
index=payload.index,
content=payload.content,
)
async def _delete_outline(self, args: dict[str, Any]) -> dict[str, Any]:
payload = DeleteOutlineInput(**args)
return await self._memory.delete_outline(index=payload.index)
async def _add_new_slide(self, args: dict[str, Any]) -> dict[str, Any]:
payload = AddNewSlideInput(**args)
return await self._memory.add_blank_slide(index=payload.index)
async def _add_new_slide_layout(self, args: dict[str, Any]) -> dict[str, Any]:
payload_args = json.loads(json.dumps(dict(args), ensure_ascii=False))
raw_content = payload_args.get("content")
if isinstance(raw_content, dict):
payload_args["content"] = json.dumps(raw_content, ensure_ascii=False)
payload = AddNewSlideLayoutInput(**payload_args)
return await self._save_slide(
{
"content": payload.content,
"layoutId": payload.layout_id,
"index": payload.index,
"replaceOldSlideAtIndex": False,
}
)
async def _update_slide(self, args: dict[str, Any]) -> dict[str, Any]:
payload_args = json.loads(json.dumps(dict(args), ensure_ascii=False))
raw_content = payload_args.get("content")
if isinstance(raw_content, dict):
payload_args["content"] = json.dumps(raw_content, ensure_ascii=False)
payload = UpdateSlideInput(**payload_args)
return await self._save_slide(
{
"content": payload.content,
"layoutId": payload.layout_id,
"index": payload.index,
"replaceOldSlideAtIndex": True,
}
)
async def _get_available_layouts(self, _: dict[str, Any]) -> dict[str, Any]:
layouts = await self._memory.get_available_layouts()
return {
"count": len(layouts),
"layouts": layouts,
}
async def _get_available_blocks(self, args: dict[str, Any]) -> dict[str, Any]:
payload = GetAvailableBlocksInput(**args)
max_results = payload.max_results if payload.max_results is not None else 20
return await self._memory.get_available_blocks(
query=payload.query,
layout_id=payload.layout_id,
element_type=payload.element_type,
block_id=payload.block_id,
include_full_content=bool(payload.include_full_content),
max_results=max_results,
)
async def _get_template_summary(self, _: dict[str, Any]) -> dict[str, Any]:
outline = await self._get_presentation_outline({})
layouts = await self._get_available_layouts({})
theme = await self._get_presentation_theme_catalog({})
return {
"outline": outline,
"available_layouts": layouts,
"theme": theme,
"message": "Template summary fetched successfully.",
}
async def _read_source_documents(self, args: dict[str, Any]) -> dict[str, Any]:
payload = ReadSourceDocumentsInput(**args)
return await self._memory.read_source_documents(
query=payload.query,
max_chars=payload.max_chars,
)
async def _get_presentation_theme_catalog(
self, _: dict[str, Any]
) -> dict[str, Any]:
return await self._memory.get_presentation_theme_catalog()
async def _get_content_schema_from_layout_id(
self, args: dict[str, Any]
) -> dict[str, Any]:
payload = GetContentSchemaFromLayoutIdInput(**args)
schema = await self._memory.get_content_schema_from_layout_id(payload.layout_id)
if schema is None:
return {
"found": False,
"layout_id": payload.layout_id,
"message": "Layout schema not found for the provided layout id.",
}
return {
"found": True,
"layout_id": payload.layout_id,
"content_schema": schema,
}
async def _generate_assets(self, args: dict[str, Any]) -> dict[str, Any]:
payload = GenerateAssetsInput(**args)
generated_assets: list[dict[str, Any]] = []
for index, asset in enumerate(payload.assets):
if asset.kind == "image":
url = await self._memory.generate_image(asset.prompt)
else:
url = await self._memory.generate_icon(asset.prompt)
generated_assets.append(
{
"index": index,
"kind": asset.kind,
"prompt": asset.prompt,
"url": url,
}
)
return {
"count": len(generated_assets),
"assets": generated_assets,
"message": f"Generated {len(generated_assets)} asset(s).",
}
async def _save_slide(self, args: dict[str, Any]) -> dict[str, Any]:
payload_args = json.loads(json.dumps(dict(args), ensure_ascii=False))
raw_content = payload_args.get("content")
if isinstance(raw_content, dict):
payload_args["content"] = json.dumps(raw_content, ensure_ascii=False)
payload = SaveSlideInput(**payload_args)
try:
content_parsed: Any = dirtyjson.loads(payload.content)
except Exception:
content_parsed = json.loads(payload.content)
if not isinstance(content_parsed, dict):
raise ValueError("'content' must be a JSON object.")
content_payload = json.loads(json.dumps(content_parsed, ensure_ascii=False))
return await self._memory.save_slide(
content=content_payload,
layout_id=payload.layout_id,
index=payload.index,
replace_old_slide_at_index=payload.replace_old_slide_at_index,
)
async def _delete_slide(self, args: dict[str, Any]) -> dict[str, Any]:
payload = DeleteSlideInput(**args)
return await self._memory.delete_slide(index=payload.index)
async def _get_slide_elements(self, args: dict[str, Any]) -> dict[str, Any]:
payload = GetSlideAtIndexInput(
index=int(args.get("index") or 0),
includeFullContent=bool(args.get("includeFullJson")),
)
return await self._memory.get_slide_ui_elements(
index=payload.index,
include_full_json=payload.include_full_content,
)
async def _add_element(self, args: dict[str, Any]) -> dict[str, Any]:
payload = AddElementInput(**args)
try:
parsed: Any = dirtyjson.loads(payload.element)
except Exception:
parsed = json.loads(payload.element)
element = json.loads(json.dumps(parsed, ensure_ascii=False))
if not isinstance(element, dict):
raise ValueError("'element' must be a JSON object.")
await self._require_reusable_block_first(
tree=element,
source_block_id=None,
primitive_tool="addElement",
)
return await self._memory.add_slide_ui_element(
index=payload.index,
element=element,
component_id=payload.component_id,
insert_index=payload.insert_index,
)
async def _update_slide_element(self, args: dict[str, Any]) -> dict[str, Any]:
payload = UpdateSlideElementInput(**args)
element_patch: dict[str, Any] | None = None
if payload.element is not None:
try:
parsed: Any = dirtyjson.loads(payload.element)
except Exception:
parsed = json.loads(payload.element)
element_patch = json.loads(json.dumps(parsed, ensure_ascii=False))
if not isinstance(element_patch, dict):
raise ValueError("'element' must be a JSON object.")
style_patch = self._element_style_patch_from_update_payload(payload)
if style_patch:
element_patch = self._merge_dict_patch(element_patch or {}, style_patch)
return await self._memory.update_slide_ui_element(
index=payload.index,
element_path=payload.element_path,
text=payload.text,
items=payload.items,
table_cell=(
payload.table_cell.model_dump(by_alias=False)
if payload.table_cell is not None
else None
),
table=(
payload.table.model_dump()
if payload.table is not None
else None
),
chart=(
payload.chart.model_dump(exclude_none=True)
if payload.chart is not None
else None
),
element_patch=element_patch,
position=(
payload.position.model_dump()
if payload.position is not None
else None
),
size=payload.size.model_dump() if payload.size is not None else None,
)
async def _update_slide_component(self, args: dict[str, Any]) -> dict[str, Any]:
payload = UpdateSlideComponentInput(**args)
return await self._memory.update_slide_ui_component(
index=payload.index,
component_id=payload.component_id,
position=(
payload.position.model_dump()
if payload.position is not None
else None
),
size=payload.size.model_dump() if payload.size is not None else None,
)
async def _update_component(self, args: dict[str, Any]) -> dict[str, Any]:
payload = UpdateComponentInput(**args)
replacement_component: dict[str, Any] | None = None
if payload.component is not None:
try:
parsed: Any = dirtyjson.loads(payload.component)
except Exception:
parsed = json.loads(payload.component)
replacement_component = json.loads(json.dumps(parsed, ensure_ascii=False))
if not isinstance(replacement_component, dict):
raise ValueError("'component' must be a JSON object.")
return await self._memory.update_slide_ui_component(
index=payload.index,
component_id=payload.component_id,
action=payload.action or "update",
component_ids=payload.component_ids,
position=(
payload.position.model_dump()
if payload.position is not None
else None
),
size=payload.size.model_dump() if payload.size is not None else None,
replacement_component=replacement_component,
)
async def _delete_slide_component(self, args: dict[str, Any]) -> dict[str, Any]:
payload = DeleteSlideComponentInput(**args)
return await self._memory.delete_slide_ui_component(
index=payload.index,
component_id=payload.component_id,
)
async def _delete_slide_element(self, args: dict[str, Any]) -> dict[str, Any]:
payload = DeleteSlideElementInput(**args)
return await self._memory.delete_slide_ui_element(
index=payload.index,
element_path=payload.element_path,
)
async def _add_slide_component(self, args: dict[str, Any]) -> dict[str, Any]:
payload = AddSlideComponentInput(**args)
try:
component_parsed: Any = dirtyjson.loads(payload.component)
except Exception:
component_parsed = json.loads(payload.component)
if not isinstance(component_parsed, dict):
raise ValueError("'component' must be a JSON object.")
component_payload = json.loads(json.dumps(component_parsed, ensure_ascii=False))
await self._require_reusable_block_first(
tree=component_payload,
source_block_id=payload.source_block_id,
primitive_tool="addComponent",
)
return await self._memory.add_slide_ui_component(
index=payload.index,
component=component_payload,
insert_index=payload.insert_index,
)
async def _set_presentation_theme(self, args: dict[str, Any]) -> dict[str, Any]:
payload = SetPresentationThemeInput(**args)
return await self._memory.set_presentation_theme(
theme_query=payload.theme,
custom_theme=(
payload.custom_theme.model_dump(exclude_none=True)
if payload.custom_theme is not None
else None
),
save_custom_theme=bool(payload.save_custom_theme),
)
@staticmethod
def _parse_args(arguments: str | None) -> dict[str, Any]:
if not arguments:
return {}
parsed = ChatTools._loads_jsonish(arguments)
normalized = _normalize_tool_argument_value(
json.loads(json.dumps(parsed, ensure_ascii=False))
)
if isinstance(normalized, dict):
return normalized
raise ValueError("Tool arguments must be a JSON object.")
async def _require_reusable_block_first(
self,
*,
tree: dict[str, Any],
source_block_id: str | None,
primitive_tool: str,
) -> None:
requested_types = self._block_prioritized_element_types(tree)
if not requested_types:
return
if source_block_id:
block_result = await self._memory.get_available_blocks(
block_id=source_block_id,
include_full_content=False,
max_results=1,
)
blocks = (
block_result.get("blocks")
if isinstance(block_result, dict)
else None
)
block = blocks[0] if isinstance(blocks, list) and blocks else None
if not isinstance(block, dict):
raise ValueError(
"sourceBlockId was provided but no matching reusable block was found. "
"Call getAvailableBlocks again and use a returned block_id."
)
block_types = {
str(item).lower()
for item in block.get("element_types", [])
if item is not None
}
if requested_types.isdisjoint(block_types):
raise ValueError(
"sourceBlockId does not match the table/chart type being inserted. "
"Use a block_id whose element_types include the requested type."
)
return
reusable = await self._first_available_reusable_block(requested_types)
if reusable is None:
return
element_type, block = reusable
block_id = str(block.get("block_id") or "")
component_id = str(block.get("component_id") or "")
layout_id = str(block.get("layout_id") or "")
raise ValueError(
f"Reusable block available for {element_type} insertion "
f"(block_id='{block_id}', component_id='{component_id}', layout_id='{layout_id}'). "
f"Do not use {primitive_tool} to create this as a primitive. "
"Call getAvailableBlocks with that blockId and includeFullContent=true, "
"adapt the returned component JSON with the requested content, then call "
f"addComponent/createComponent with sourceBlockId='{block_id}'."
)
async def _first_available_reusable_block(
self,
requested_types: set[str],
) -> tuple[str, dict[str, Any]] | None:
for element_type in sorted(requested_types):
block_result = await self._memory.get_available_blocks(
element_type=element_type,
include_full_content=False,
max_results=1,
)
blocks = (
block_result.get("blocks")
if isinstance(block_result, dict)
else None
)
block = blocks[0] if isinstance(blocks, list) and blocks else None
if isinstance(block, dict):
return element_type, block
return None
@staticmethod
def _block_prioritized_element_types(tree: Any) -> set[str]:
found: set[str] = set()
def visit(value: Any) -> None:
if isinstance(value, dict):
element_type = value.get("type")
if isinstance(element_type, str):
normalized = element_type.strip().lower()
if normalized in BLOCK_PRIORITIZED_INSERT_TYPES:
found.add(normalized)
for nested in value.values():
visit(nested)
elif isinstance(value, list):
for nested in value:
visit(nested)
visit(tree)
return found
@staticmethod
def _element_style_patch_from_update_payload(
payload: UpdateSlideElementInput,
) -> dict[str, Any]:
patch: dict[str, Any] = {}
if payload.font is not None:
font = payload.font.model_dump(exclude_none=True)
if font:
patch["font"] = font
if payload.alignment is not None:
alignment = payload.alignment.model_dump(exclude_none=True)
if alignment:
patch["alignment"] = alignment
if payload.fill is not None:
fill = payload.fill.model_dump(exclude_none=True)
if fill:
patch["fill"] = fill
if payload.stroke is not None:
stroke = payload.stroke.model_dump(exclude_none=True)
if stroke:
patch["stroke"] = stroke
if payload.color is not None:
patch["color"] = payload.color
if payload.opacity is not None:
patch["opacity"] = payload.opacity
return patch
@staticmethod
def _merge_dict_patch(
target: dict[str, Any],
patch: dict[str, Any],
) -> dict[str, Any]:
merged = json.loads(json.dumps(target, ensure_ascii=False))
for key, value in patch.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = ChatTools._merge_dict_patch(merged[key], value)
else:
merged[key] = json.loads(json.dumps(value, ensure_ascii=False))
return merged
def _repair_tool_args(
self,
tool_name: str,
args: dict[str, Any],
*,
error: str | None = None,
) -> tuple[dict[str, Any], list[str]]:
repaired = dict(args)
notes: list[str] = []
self._repair_json_object_string_fields(tool_name, repaired, notes)
repaired = self._repair_chart_insert_args(
tool_name,
repaired,
notes,
error=error,
)
repaired = self._repair_table_insert_args(
tool_name,
repaired,
notes,
error=error,
)
repaired = self._repair_image_insert_args(
tool_name,
repaired,
notes,
error=error,
)
return repaired, notes
@staticmethod
def _repair_json_object_string_fields(
tool_name: str,
args: dict[str, Any],
notes: list[str],
) -> None:
for field_name in JSON_OBJECT_STRING_FIELDS.get(tool_name, ()):
if field_name not in args:
continue
value = args.get(field_name)
if value is None:
continue
if isinstance(value, dict):
args[field_name] = json.dumps(value, ensure_ascii=False)
notes.append(
f"Converted {field_name} from object to JSON string."
)
continue
if not isinstance(value, str):
continue
parsed = ChatTools._loads_jsonish_object(value)
if parsed is None:
continue
canonical = json.dumps(parsed, ensure_ascii=False)
if canonical != value:
args[field_name] = canonical
notes.append(f"Repaired JSON string field {field_name}.")
def _repair_chart_insert_args(
self,
tool_name: str,
args: dict[str, Any],
notes: list[str],
*,
error: str | None = None,
) -> dict[str, Any]:
payload_field = CHART_INSERT_TOOL_FIELDS.get(tool_name)
if not payload_field or payload_field not in args:
return args
chart_rows = self._extract_chart_rows_from_user_message(
self._turn_user_message,
)
if not chart_rows:
return args
payload = self._parse_json_object_field(args.get(payload_field))
if payload is None:
return args
title = self._infer_chart_title_from_user_message(self._turn_user_message)
if not self._inject_missing_chart_data(payload, chart_rows, title):
return args
repaired = dict(args)
repaired[payload_field] = json.dumps(payload, ensure_ascii=False)
notes.append(
"Filled missing chart categories and series.values from the latest user message."
)
return repaired
def _repair_table_insert_args(
self,
tool_name: str,
args: dict[str, Any],
notes: list[str],
*,
error: str | None = None,
) -> dict[str, Any]:
payload_field = TABLE_INSERT_TOOL_FIELDS.get(tool_name)
if not payload_field or payload_field not in args:
return args
table_data = self._extract_table_from_user_message(self._turn_user_message)
if table_data is None:
return args
payload = self._parse_json_object_field(args.get(payload_field))
if payload is None:
return args
if not self._inject_missing_table_data(payload, table_data):
return args
repaired = dict(args)
repaired[payload_field] = json.dumps(payload, ensure_ascii=False)
notes.append(
"Filled missing table headers/columns and rows from the latest user message."
)
return repaired
def _repair_image_insert_args(
self,
tool_name: str,
args: dict[str, Any],
notes: list[str],
*,
error: str | None = None,
) -> dict[str, Any]:
payload_field = IMAGE_INSERT_TOOL_FIELDS.get(tool_name)
if not payload_field or payload_field not in args:
return args
payload = self._parse_json_object_field(args.get(payload_field))
if payload is None:
return args
if not self._inject_missing_image_data(payload):
return args
repaired = dict(args)
repaired[payload_field] = json.dumps(payload, ensure_ascii=False)
notes.append(
"Filled missing image data from the generated asset URL in this turn."
)
return repaired
@staticmethod
def _parse_json_object_field(value: Any) -> dict[str, Any] | None:
if isinstance(value, dict):
return json.loads(json.dumps(value, ensure_ascii=False))
if not isinstance(value, str) or not value.strip():
return None
parsed = ChatTools._loads_jsonish_object(value)
if not isinstance(parsed, dict):
return None
return json.loads(json.dumps(parsed, ensure_ascii=False))
def _remember_generated_assets(self, result: dict[str, Any]) -> None:
assets = result.get("assets") if isinstance(result, dict) else None
if not isinstance(assets, list):
return
for asset in assets:
if not isinstance(asset, dict):
continue
url = asset.get("url")
if not isinstance(url, str) or not url.strip():
continue
self._generated_assets.append(
{
"kind": str(asset.get("kind") or "image"),
"prompt": str(asset.get("prompt") or ""),
"url": url.strip(),
}
)
@staticmethod
def _loads_jsonish_object(value: str) -> dict[str, Any] | None:
try:
parsed = ChatTools._loads_jsonish(value)
except Exception:
return None
return parsed if isinstance(parsed, dict) else None
@staticmethod
def _loads_jsonish(value: str) -> Any:
last_exc: Exception | None = None
for candidate in ChatTools._jsonish_candidates(value):
try:
return dirtyjson.loads(candidate)
except Exception as exc:
last_exc = exc
try:
return json.loads(candidate)
except Exception as exc:
last_exc = exc
if last_exc:
raise last_exc
raise ValueError("JSON value is empty.")
@staticmethod
def _jsonish_candidates(value: str) -> list[str]:
stripped = (value or "").strip()
if not stripped:
return []
candidates = [stripped]
fence_match = re.search(
r"```(?:json|javascript|js)?\s*(.*?)```",
stripped,
flags=re.IGNORECASE | re.DOTALL,
)
if fence_match:
candidates.append(fence_match.group(1).strip())
for opener, closer in (("{", "}"), ("[", "]")):
start = stripped.find(opener)
end = stripped.rfind(closer)
if start != -1 and end > start:
candidates.append(stripped[start : end + 1].strip())
unique: list[str] = []
seen: set[str] = set()
for candidate in candidates:
if not candidate or candidate in seen:
continue
seen.add(candidate)
unique.append(candidate)
return unique
@classmethod
def _inject_missing_chart_data(
cls,
node: Any,
rows: list[dict[str, Any]],
title: str,
) -> bool:
from services.chat.slide_ui_helpers import _chart_element_has_explicit_data
changed = False
if isinstance(node, dict):
if node.get("type") == "chart" and not _chart_element_has_explicit_data(node):
categories = [row["label"] for row in rows]
values = [row["value"] for row in rows]
node.setdefault("chart_type", "bar")
node.setdefault("title", title)
node["categories"] = categories
node["series"] = [{"name": title or "Series 1", "values": values}]
node["data"] = [
{"label": row["label"], "value": row["value"]}
for row in rows
]
changed = True
for value in node.values():
changed = cls._inject_missing_chart_data(value, rows, title) or changed
elif isinstance(node, list):
for value in node:
changed = cls._inject_missing_chart_data(value, rows, title) or changed
return changed
@classmethod
def _inject_missing_table_data(
cls,
node: Any,
table_data: dict[str, Any],
) -> bool:
from services.chat.slide_ui_helpers import _table_element_has_explicit_data
changed = False
if isinstance(node, dict):
if node.get("type") == "table" and not _table_element_has_explicit_data(node):
columns = list(table_data["columns"])
rows = [list(row) for row in table_data["rows"]]
node["columns"] = columns
node["rows"] = rows
node.setdefault("min_columns", 1)
node.setdefault("max_columns", max(len(columns), 1))
node.setdefault("min_rows", 1)
node.setdefault("max_rows", max(len(rows), 1))
changed = True
for value in node.values():
changed = cls._inject_missing_table_data(value, table_data) or changed
elif isinstance(node, list):
for value in node:
changed = cls._inject_missing_table_data(value, table_data) or changed
return changed
def _inject_missing_image_data(self, node: Any) -> bool:
from services.chat.slide_ui_helpers import _image_element_has_explicit_data
changed = False
if isinstance(node, dict):
if node.get("type") == "image" and not _image_element_has_explicit_data(node):
asset = self._latest_generated_asset_for_image(
is_icon=node.get("is_icon") is True,
)
if asset is not None:
node["data"] = asset["url"]
node.setdefault("is_icon", asset.get("kind") == "icon")
prompt = asset.get("prompt")
if isinstance(prompt, str) and prompt.strip():
node.setdefault("prompt", prompt.strip())
changed = True
for value in node.values():
changed = self._inject_missing_image_data(value) or changed
elif isinstance(node, list):
for value in node:
changed = self._inject_missing_image_data(value) or changed
return changed
def _latest_generated_asset_for_image(
self,
*,
is_icon: bool,
) -> dict[str, Any] | None:
preferred_kind = "icon" if is_icon else "image"
for asset in reversed(self._generated_assets):
if asset.get("kind") == preferred_kind and asset.get("url"):
return asset
for asset in reversed(self._generated_assets):
if asset.get("url"):
return asset
return None
@classmethod
def _extract_chart_rows_from_user_message(
cls,
user_message: str,
) -> list[dict[str, Any]]:
text = cls._strip_ui_context_prefix(user_message)
if not text:
return []
rows: list[dict[str, Any]] = []
seen_labels: set[str] = set()
segments = re.split(r"[\n;,|]+|\s+\band\b\s+", text, flags=re.IGNORECASE)
for segment in segments:
segment = segment.strip(" \t\r\n.:-–—")
if not segment:
continue
for match in re.finditer(
r"(?P<label>[A-Za-z][A-Za-z0-9&'./() -]{0,80}?)"
r"\s*(?:[:=\-–—]|\bhas\b|\bhave\b|\bwith\b)?\s+"
r"(?P<value>[-+]?(?:\d[\d,]*(?:\.\d+)?|\.\d+))\b",
segment,
flags=re.IGNORECASE,
):
label = cls._clean_chart_label(match.group("label"))
if not label:
continue
number = cls._parse_chart_number(match.group("value"))
if number is None:
continue
normalized_label = label.lower()
if normalized_label in seen_labels:
continue
seen_labels.add(normalized_label)
rows.append({"label": label, "value": number})
if len(rows) >= 12:
return rows
return rows if len(rows) >= 2 else []
@classmethod
def _extract_table_from_user_message(
cls,
user_message: str,
) -> dict[str, Any] | None:
text = cls._strip_ui_context_prefix(user_message)
if not text:
return None
marker_match = re.search(r"\bdata\s*:\s*(?P<data>.+)$", text, re.IGNORECASE | re.DOTALL)
if marker_match:
header_text = text[: marker_match.start()]
headers = cls._extract_table_headers(header_text)
rows = cls._parse_table_rows(marker_match.group("data"))
if headers and rows:
normalized_rows = cls._align_table_rows(rows, len(headers))
if normalized_rows:
return {"columns": headers, "rows": normalized_rows}
csv_rows = cls._parse_table_rows(text, row_split_pattern=r"[\n;]+")
if len(csv_rows) >= 2:
headers = csv_rows[0]
rows = cls._align_table_rows(csv_rows[1:], len(headers))
if headers and rows:
return {"columns": headers, "rows": rows}
return None
@classmethod
def _extract_table_headers(cls, text: str) -> list[str]:
patterns = (
r"\bfirst\s+row\s+with\s+(?P<headers>[^.\n;]+)",
r"\bheaders?\s*(?:are|with|:)?\s+(?P<headers>[^.\n;]+)",
r"\bcolumns?\s*(?:are|with|:)?\s+(?P<headers>[^.\n;]+)",
)
for pattern in patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
if not match:
continue
headers = cls._split_table_cells(match.group("headers"))
if len(headers) >= 1:
return headers
return []
@classmethod
def _parse_table_rows(
cls,
text: str,
*,
row_split_pattern: str = r"[;\n]+",
) -> list[list[str]]:
rows: list[list[str]] = []
for raw_row in re.split(row_split_pattern, text):
cells = cls._split_table_cells(raw_row)
if cells:
rows.append(cells)
return rows
@staticmethod
def _split_table_cells(text: str) -> list[str]:
cells = [
cell.strip(" \t\r\n.:-–—")
for cell in re.split(r"\s*\|\s*|\s*,\s*", text or "")
]
return [cell for cell in cells if cell]
@staticmethod
def _align_table_rows(rows: list[list[str]], column_count: int) -> list[list[str]]:
aligned: list[list[str]] = []
for row in rows:
if len(row) == column_count:
aligned.append(row)
return aligned
@staticmethod
def _clean_chart_label(value: str) -> str:
label = re.sub(r"\s+", " ", value or "").strip(" \t\r\n.:-–—")
label = re.sub(
r"^(?:and|the|for|category|label|value|metric|series)\s+",
"",
label,
flags=re.IGNORECASE,
).strip(" \t\r\n.:-–—")
if not label:
return ""
if label.lower() in {
"slide",
"index",
"chart",
"bar chart",
"line chart",
"user message",
}:
return ""
if label == label.lower():
return label[:1].upper() + label[1:]
return label
@staticmethod
def _parse_chart_number(value: str) -> float | int | None:
try:
number = float(str(value).replace(",", ""))
except ValueError:
return None
if not number == number or number in {float("inf"), float("-inf")}:
return None
return int(number) if number.is_integer() else number
@classmethod
def _infer_chart_title_from_user_message(cls, user_message: str) -> str:
text = cls._strip_ui_context_prefix(user_message)
quoted_title = re.search(
r"\b(?:titled|called|named)\s+[\"']([^\"']{1,80})[\"']",
text,
flags=re.IGNORECASE,
)
if quoted_title:
return quoted_title.group(1).strip()
count_title = re.search(
r"\b(?:number\s+of|no\.?\s*of|no\s+of)\s+"
r"(?P<title>[A-Za-z][A-Za-z ]{1,40}?)"
r"(?:\s+(?:the|for|by|of|that|which)\b|[.,;\n]|$)",
text,
flags=re.IGNORECASE,
)
if count_title:
title = count_title.group("title").strip()
if title:
return title[:1].upper() + title[1:]
lowered = text.lower()
for keyword, title in (
("goal", "Goals"),
("revenue", "Revenue"),
("sales", "Sales"),
("profit", "Profit"),
("age", "Age"),
):
if keyword in lowered:
return title
return "Chart"
@staticmethod
def _strip_ui_context_prefix(user_message: str) -> str:
marker = "\nUser message:"
if not user_message.startswith("UI context:"):
return user_message.strip()
marker_index = user_message.find(marker)
if marker_index == -1:
return user_message.strip()
return user_message[marker_index + len(marker) :].lstrip()
@staticmethod
def _args_equivalent(left: dict[str, Any], right: dict[str, Any]) -> bool:
return json.dumps(
left,
sort_keys=True,
ensure_ascii=False,
default=str,
) == json.dumps(
right,
sort_keys=True,
ensure_ascii=False,
default=str,
)
@classmethod
def _build_tool_recovery(
cls,
*,
tool_name: str,
args: dict[str, Any] | None,
error: str,
) -> dict[str, Any]:
normalized_error = (error or "").lower()
guidance: list[str] = []
expected: dict[str, Any] = {}
retryable = True
if (
tool_name not in CHART_INSERT_TOOL_FIELDS
and tool_name not in JSON_OBJECT_STRING_FIELDS
):
guidance.append("Review the tool schema and retry with corrected arguments.")
if "reusable block available" in normalized_error:
guidance.append(
"Use getAvailableBlocks with includeFullContent=true for the "
"returned block_id, adapt that component JSON, then retry with "
"addComponent/createComponent and sourceBlockId."
)
expected["block_workflow"] = {
"discovery": {
"tool": "getAvailableBlocks",
"arguments": {
"blockId": "returned block_id",
"includeFullContent": True,
},
},
"insert": {
"tool": "addComponent",
"arguments": {
"component": "JSON string adapted from returned block.component",
"sourceBlockId": "returned block_id",
},
},
}
json_fields = JSON_OBJECT_STRING_FIELDS.get(tool_name, ())
if json_fields:
fields = ", ".join(json_fields)
guidance.append(
f"Ensure {fields} is a JSON-serialized object string, not prose."
)
expected["json_object_string_fields"] = list(json_fields)
if "chart elements must include numeric data" in normalized_error:
guidance.append(
"For chart elements include categories and series with numeric values before retrying."
)
expected["chart"] = {
"type": "chart",
"chart_type": "bar",
"categories": ["Label A", "Label B"],
"series": [{"name": "Series name", "values": [1, 2]}],
}
if "table elements must include" in normalized_error:
guidance.append(
"For table elements include columns or headers plus rows before retrying."
)
expected["table"] = {
"type": "table",
"columns": ["Name", "Age", "Department"],
"rows": [["Ghanshyam", "30", "QA"], ["Sudeep", "33", "AI"]],
}
if "image elements must include" in normalized_error:
guidance.append(
"For image elements call generateAssets first, then include the returned URL as data before retrying."
)
expected["image"] = {
"type": "image",
"data": "/app_data/images/generated.png",
"is_icon": False,
}
if "validation error" in normalized_error:
guidance.append(
"Use the field names and aliases from the tool schema, include required nullable fields as null, and remove unsupported keys."
)
if "json" in normalized_error or "expecting value" in normalized_error:
guidance.append(
"Return valid JSON only for tool arguments; avoid Markdown fences in tool-call arguments."
)
if "no slide found" in normalized_error or "was not found" in normalized_error:
guidance.append(
"Inspect the deck with getSlideAtIndex or searchSlide and retry with the returned slide index, componentId, or elementPath."
)
if "unsupported tool" in normalized_error:
retryable = False
guidance = ["Choose one of the available tool names from the current tool definitions."]
if not guidance:
guidance.append("Fix the arguments based on the error and retry once.")
recovery: dict[str, Any] = {
"retryable": retryable,
"message": "Repair the tool arguments before retrying." if retryable else "Do not retry this exact tool call.",
"guidance": guidance,
}
if expected:
recovery["expected"] = expected
if args is not None:
recovery["received_keys"] = sorted(str(key) for key in args.keys())
return recovery
@staticmethod
def _extract_title(markdown_content: str) -> str:
for line in markdown_content.splitlines():
stripped = line.strip()
if not stripped:
continue
heading_match = re.match(r"^#{1,6}\s*(.+?)\s*$", stripped)
if heading_match:
return heading_match.group(1).strip()
return stripped[:120]
return ""
@staticmethod
def _truncate(value: str, limit: int) -> str:
if len(value) <= limit:
return value
return f"{value[:limit]}..."