import asyncio import copy from datetime import datetime import json import logging import os import random import re import traceback from typing import Annotated, Any, List, Literal, Optional, Tuple import dirtyjson from fastapi import ( APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path, Query, Request, ) from fastapi.responses import StreamingResponse from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select from constants.presentation import DEFAULT_TEMPLATES, MAX_NUMBER_OF_SLIDES from enums.webhook_event import WebhookEvent from models.api_error_model import APIErrorModel from models.generate_presentation_request import GeneratePresentationRequest from models.presentation_and_path import PresentationPathAndEditPath from models.presentation_from_template import EditPresentationRequest from models.presentation_outline_model import ( PresentationOutlineModel, SlideOutlineModel, ) from enums.tone import Tone from enums.verbosity import Verbosity from models.presentation_structure_model import PresentationStructureModel from models.presentation_with_slides import ( PresentationDetailWithSlides, PresentationWithSlides, ) from services.documents_loader import DocumentsLoader from services.temp_file_service import TEMP_FILE_SERVICE from services.webhook_service import WebhookService from services.image_generation_service import ImageGenerationService from services.mem0_presentation_memory_service import ( MEM0_PRESENTATION_MEMORY_SERVICE, ) from utils.dict_utils import deep_update from utils.export_utils import export_presentation from utils.llm_calls.generate_presentation_outlines import ( generate_ppt_outline, get_messages as get_outline_messages, ) from models.sql.slide import SlideModel from models.sql.presentation_layout_code import PresentationLayoutCodeModel from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse from services.database import get_async_session from services.database import async_session_maker from services.concurrent_service import CONCURRENT_SERVICE from models.sql.presentation import PresentationModel, PresentationVersion from models.sql.template_v2 import TemplateV2 from models.sql.async_task import AsyncTaskModel from utils.asset_directory_utils import get_images_directory from utils.llm_calls.generate_presentation_structure import ( generate_presentation_structure, ) from utils.llm_calls.generate_slide_content import ( get_slide_content_from_type_and_outline, ) from utils.ppt_utils import ( select_toc_or_list_slide_layout_index, ) from utils.outline_utils import ( get_images_for_slides_from_outline, get_no_of_outlines_to_generate_for_n_slides, get_no_of_toc_required_for_n_outlines, get_presentation_outline_model_with_toc, get_presentation_title_from_presentation_outline, ) from utils.outline_limits import normalize_outline_payload from utils.process_slides import ( process_slide_add_placeholder_assets, process_slide_and_fetch_assets, ) from utils.get_layout_by_name import get_layout_by_name from utils.llm_utils import message_content_to_text from utils.sse import safe_sse_stream from utils.simple_auth import ( SESSION_COOKIE_NAME, create_session_token, get_session_token_from_request, ) from utils.web_search import get_selected_web_search_provider, get_web_search_route from models.presentation_layout import PresentationLayoutModel, SlideLayoutModel from templates.v2.schema import get_template_schema import uuid logger = logging.getLogger(__name__) PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"]) ASYNC_TASK_TYPE_PRESENTATION_GENERATE = "presentation.generate" def _presentation_task_progress_data( created_slides: int, remaining_slides: int, ) -> dict[str, int]: return { "created_slides": max(created_slides, 0), "remaining_slides": max(remaining_slides, 0), } def _requested_slide_count(request: GeneratePresentationRequest) -> int: if request.slides_markdown: return len(request.slides_markdown) return request.n_slides or 0 def _extract_custom_template_id(layout_name: Optional[str]) -> Optional[uuid.UUID]: if not layout_name or not layout_name.startswith("custom-"): return None try: return uuid.UUID(layout_name.replace("custom-", "")) except Exception: return None def _extract_template_v2_id(layout_name: Optional[str]) -> Optional[str]: if not isinstance(layout_name, str): return None layout_name = layout_name.strip() if not layout_name: return None for prefix in ("template-v2-", "template-v2:"): if layout_name.startswith(prefix): candidate = layout_name[len(prefix) :].strip() return candidate or None return None def _extract_requested_template_v2_id( template_name: Optional[str], *, allow_bare: bool = False, ) -> Optional[str]: if not isinstance(template_name, str): return None value = template_name.strip() if not value: return None for prefix in ("template-v2-", "template-v2:", "custom-"): if not value.startswith(prefix): continue candidate = value[len(prefix) :] return candidate.strip() or None return value if allow_bare else None def _extract_template_v2_metadata_id(key: str, value: Any) -> Optional[str]: if not isinstance(value, str): return None prefixed = _extract_template_v2_id(value) if prefixed: return prefixed if key in {"template_id", "template_v2_id"}: candidate = value.strip() return candidate or None return None async def _resolve_requested_template_v2( template_name: str, sql_session: AsyncSession, *, allow_bare: bool = False, ) -> Optional[TemplateV2]: template_id = _extract_requested_template_v2_id( template_name, allow_bare=allow_bare, ) if not template_id: return None template = await sql_session.get(TemplateV2, template_id) if template: return template raise HTTPException( status_code=400, detail="Template not found. Please use a valid template.", ) def _copy_template_v2_layout_payload( template: TemplateV2, ) -> dict[str, Any]: layout_payload = copy.deepcopy(template.layouts) if not isinstance(layout_payload, dict): raise HTTPException( status_code=400, detail="Template v2 layout JSON must be an object", ) return layout_payload async def _resolve_generation_layout( template_name: str, sql_session: AsyncSession, ) -> tuple[dict[str, Any], PresentationLayoutModel, Optional[dict[str, str]], bool]: template_v2 = await _resolve_requested_template_v2(template_name, sql_session) if template_v2: layout_payload = _copy_template_v2_layout_payload(template_v2) return ( layout_payload, _build_template_v2_structure_layout(template_v2, layout_payload), _extract_template_v2_fonts_from_assets(template_v2.assets), True, ) if template_name not in DEFAULT_TEMPLATES: raise HTTPException( status_code=400, detail="Template not found. Please use a valid template.", ) layout_model = await get_layout_by_name(template_name) return layout_model.model_dump(mode="json"), layout_model, None, False def _is_template_v2_slide(slide: SlideModel) -> bool: return slide.layout_group.startswith("template-v2") or slide.layout.startswith( "template-v2" ) def _hydrate_template_v2_slide_ui( slide: SlideModel, layout_payload: Any = None, ) -> None: if not _is_template_v2_slide(slide): return ui = slide.ui if not isinstance(ui, dict): ui = _template_v2_slide_ui(layout_payload, slide.layout) slide.ui = _apply_template_v2_content_to_ui(ui, slide.content) def _canonical_template_v2_layout_payload(layout_payload: Any) -> Optional[str]: if not _is_template_v2_layout_payload(layout_payload): return None comparable_payload = copy.deepcopy(layout_payload) if isinstance(comparable_payload, dict): for metadata_key in ("name", "template_id", "template_v2_id"): comparable_payload.pop(metadata_key, None) try: return json.dumps( comparable_payload, sort_keys=True, separators=(",", ":"), ) except (TypeError, ValueError): return None def _coerce_presentation_font_map(value: Any) -> Optional[dict[str, str]]: if not isinstance(value, dict): return None fonts = { name.strip(): url.strip() for name, url in value.items() if isinstance(name, str) and isinstance(url, str) and name.strip() and url.strip() } return fonts or None def _extract_template_v2_fonts_from_assets(assets: Any) -> Optional[dict[str, str]]: if not isinstance(assets, dict): return None return _coerce_presentation_font_map(assets.get("fonts")) async def _resolve_presentation_template_v2_fonts( presentation: PresentationModel, slides: List[SlideModel], sql_session: AsyncSession, ): candidate_template_ids: List[str] = [] seen: set[str] = set() if isinstance(presentation.layout, dict): for key in ("name", "template_id", "template_v2_id"): value = presentation.layout.get(key) template_id = _extract_template_v2_metadata_id(key, value) if template_id and template_id not in seen: candidate_template_ids.append(template_id) seen.add(template_id) for slide in slides: for value in (slide.layout_group, slide.layout): template_id = _extract_template_v2_id(value) if template_id and template_id not in seen: candidate_template_ids.append(template_id) seen.add(template_id) for template_id in candidate_template_ids: template = await sql_session.get(TemplateV2, template_id) if template: fonts = _extract_template_v2_fonts_from_assets(template.assets) if fonts is not None: return fonts target_layout_payload = _canonical_template_v2_layout_payload(presentation.layout) if not target_layout_payload: return None try: result = await sql_session.execute( select(TemplateV2.id, TemplateV2.layouts, TemplateV2.assets) ) for _template_id, layouts, assets in result.all(): if _canonical_template_v2_layout_payload(layouts) != target_layout_payload: continue fonts = _extract_template_v2_fonts_from_assets(assets) if fonts is not None: return fonts except Exception: logger.exception("[presentation.detail] failed to resolve template v2 fonts") return None async def _resolve_presentation_fonts( presentation: PresentationModel, slides: List[SlideModel], sql_session: AsyncSession, ): stored_fonts = _coerce_presentation_font_map(getattr(presentation, "fonts", None)) if stored_fonts is not None: return stored_fonts candidate_template_ids: List[uuid.UUID] = [] seen: set[uuid.UUID] = set() layout_name = None if isinstance(presentation.layout, dict): layout_name = presentation.layout.get("name") layout_template_id = _extract_custom_template_id(layout_name) if layout_template_id and layout_template_id not in seen: candidate_template_ids.append(layout_template_id) seen.add(layout_template_id) for slide in slides: template_id = _extract_custom_template_id(slide.layout_group) if template_id and template_id not in seen: candidate_template_ids.append(template_id) seen.add(template_id) for template_id in candidate_template_ids: result = await sql_session.execute( select(PresentationLayoutCodeModel.fonts).where( PresentationLayoutCodeModel.presentation == template_id ) ) fonts_list = result.scalars().all() for fonts in fonts_list: if fonts is not None: return fonts return await _resolve_presentation_template_v2_fonts( presentation, slides, sql_session, ) def _presentation_response_data(presentation: PresentationModel) -> dict: return presentation.model_dump(exclude={"fonts"}) async def _resolve_presentation_template_v2_payload( presentation: PresentationModel, slides: List[SlideModel], sql_session: AsyncSession, payload_field: Literal["components", "merged_components"], ): candidate_template_ids: List[str] = [] seen: set[str] = set() if isinstance(presentation.layout, dict): for key in ("name", "template_id", "template_v2_id"): value = presentation.layout.get(key) template_id = _extract_template_v2_metadata_id(key, value) if template_id and template_id not in seen: candidate_template_ids.append(template_id) seen.add(template_id) for slide in slides: for value in (slide.layout_group, slide.layout): template_id = _extract_template_v2_id(value) if template_id and template_id not in seen: candidate_template_ids.append(template_id) seen.add(template_id) for template_id in candidate_template_ids: template = await sql_session.get(TemplateV2, template_id) if template: payload = getattr(template, payload_field, None) if payload is not None: return payload target_layout_payload = _canonical_template_v2_layout_payload(presentation.layout) if not target_layout_payload: return None try: payload_column = getattr(TemplateV2, payload_field) result = await sql_session.execute( select(TemplateV2.id, TemplateV2.layouts, payload_column) ) for _template_id, layouts, payload in result.all(): if payload is None: continue if _canonical_template_v2_layout_payload(layouts) == target_layout_payload: return payload except Exception: logger.exception( "[presentation.detail] failed to resolve template v2 %s", payload_field, ) return None async def _resolve_presentation_merged_components( presentation: PresentationModel, slides: List[SlideModel], sql_session: AsyncSession, ): return await _resolve_presentation_template_v2_payload( presentation, slides, sql_session, "merged_components", ) def _insert_toc_layouts( structure: PresentationStructureModel, n_toc_slides: int, include_title_slide: bool, toc_slide_layout_index: int, ): if n_toc_slides <= 0 or toc_slide_layout_index == -1: return insertion_index = 1 if include_title_slide else 0 for i in range(n_toc_slides): structure.slides.insert(insertion_index + i, toc_slide_layout_index) def _layout_count(layout_payload: Any) -> int: if isinstance(layout_payload, dict): layouts = layout_payload.get("layouts", layout_payload.get("slides")) return len(layouts) if isinstance(layouts, list) else 0 if isinstance(layout_payload, list): return len(layout_payload) return 0 def _build_template_v2_layout_model( layout_payload: dict[str, Any], *, layout_name: str, ) -> PresentationLayoutModel: try: template_schema = get_template_schema(layout_payload) except ValueError as exc: raise HTTPException( status_code=400, detail=f"Invalid template v2 layout JSON: {exc}", ) from exc source_layouts = layout_payload.get("layouts") if not isinstance(source_layouts, list): source_layouts = [] slides: list[SlideLayoutModel] = [] for index, schema_layout in enumerate(template_schema["layouts"]): if not isinstance(schema_layout, dict): continue source_layout = ( source_layouts[index] if index < len(source_layouts) and isinstance(source_layouts[index], dict) else {} ) layout_id = ( schema_layout.get("layout_id") or source_layout.get("id") or f"layout_{index + 1}" ) layout_schema = schema_layout.get("schema") if not isinstance(layout_schema, dict): layout_schema = { "title": str(layout_id), "description": source_layout.get("description"), } slides.append( SlideLayoutModel( id=str(layout_id), name=source_layout.get("name") or layout_schema.get("title"), description=source_layout.get("description") or layout_schema.get("description"), json_schema=layout_schema, ) ) if not slides: raise HTTPException( status_code=400, detail="Template v2 layout JSON must contain at least one layout", ) return PresentationLayoutModel( name=layout_name, ordered=False, slides=slides, ) def _build_template_v2_structure_layout( template: TemplateV2, layout_payload: dict[str, Any], ) -> PresentationLayoutModel: return _build_template_v2_layout_model( layout_payload, layout_name=f"template-v2-{template.id}", ) def _is_template_v2_layout_payload(layout_payload: Any) -> bool: return ( isinstance(layout_payload, dict) and isinstance(layout_payload.get("layouts"), list) ) def _template_v2_slide_ui( layout_payload: Any, layout_id: str, ) -> Optional[dict[str, Any]]: if not _is_template_v2_layout_payload(layout_payload): return None for layout in layout_payload["layouts"]: if isinstance(layout, dict) and str(layout.get("id")) == str(layout_id): return copy.deepcopy(layout) return None GENERATED_VALUE_ELEMENT_TYPES = {"text", "image", "text-list", "table", "chart"} GENERATED_TABLE_TEXT_FONT = { "family": "Sniglet", "size": 12, "color": "#082314", } GENERATED_TABLE_HEADER_FONT = { **GENERATED_TABLE_TEXT_FONT, "bold": True, } GENERATED_TABLE_CELL_FILL = { "color": "#F8F4E9", "opacity": 1, } GENERATED_TABLE_CELL_STROKE = { "color": "#D8D3C4", "opacity": 1, "width": 1, } TEMPLATE_V2_STRONG_MARKDOWN_DELIMITERS = ("**", "__") TEMPLATE_V2_EMPHASIS_MARKDOWN_DELIMITERS = ("*", "_") TEMPLATE_V2_MARKDOWN_DELIMITERS = ( *TEMPLATE_V2_STRONG_MARKDOWN_DELIMITERS, *TEMPLATE_V2_EMPHASIS_MARKDOWN_DELIMITERS, ) def _template_v2_component_content_keys(components: list[Any]) -> list[str]: ids: list[str] = [] for index, component in enumerate(components): component_id = ( component.get("id") if isinstance(component, dict) and isinstance(component.get("id"), str) else None ) ids.append(component_id or f"component_{index}") counts: dict[str, int] = {} for component_id in ids: counts[component_id] = counts.get(component_id, 0) + 1 indexes: dict[str, int] = {} used: set[str] = set() keys: list[str] = [] for component_id in ids: occurrence_index = indexes.get(component_id, 0) indexes[component_id] = occurrence_index + 1 base = ( f"{component_id}_{occurrence_index}" if counts.get(component_id, 0) > 1 else component_id ) key = base suffix = 1 while key in used: key = f"{base}_{suffix}" suffix += 1 used.add(key) keys.append(key) return keys def _apply_template_v2_content_to_ui( ui: Optional[dict[str, Any]], content: dict[str, Any], ) -> Optional[dict[str, Any]]: if not isinstance(ui, dict): return ui components = ui.get("components") if not isinstance(components, list) or not components: return ui component_keys = _template_v2_component_content_keys(components) hydrated_ui = copy.deepcopy(ui) hydrated_components = hydrated_ui.get("components") if not isinstance(hydrated_components, list): return hydrated_ui for index, component in enumerate(hydrated_components): if not isinstance(component, dict): continue component_id = component.get("id") component_content = content.get(component_keys[index]) if not isinstance(component_content, dict) and isinstance(component_id, str): component_content = content.get(component_id) if not isinstance(component_content, dict): component_content = {} elements = component.get("elements") if isinstance(elements, list): component["elements"] = [ _apply_template_v2_content_to_element(element, component_content) for element in elements ] return hydrated_ui def _apply_template_v2_content_to_element( element: Any, content: Any, *, direct_value: bool = False, ) -> Any: if not isinstance(element, dict): return element content_values = content if isinstance(content, dict) else {} element_type = element.get("type") name = element.get("name") if isinstance(element.get("name"), str) else None has_value = False value = None if name: has_value, value = _template_v2_content_value(content_values, name) if ( element.get("decorative") is False and name and has_value and element_type in GENERATED_VALUE_ELEMENT_TYPES ): return _apply_template_v2_content_value(element, value) # Repeated flex/grid schemas omit the child-name wrapper when an item is a # direct generated value. For example, three image children are emitted as # [{"image_prompt": ..., "image_url": ...}, ...], not as # [{"gallery_photo": {...}}, ...]. In that case the array item itself is # the value for the child element. if ( direct_value and not has_value and element.get("decorative") is False and element_type in GENERATED_VALUE_ELEMENT_TYPES ): return _apply_template_v2_content_value(element, content) nested_content = value if isinstance(value, dict) else content_values nested_direct_value = direct_value and not has_value if element_type == "container": updated = copy.deepcopy(element) updated["child"] = _apply_template_v2_content_to_element( element.get("child"), nested_content, direct_value=nested_direct_value, ) return updated if element_type in {"flex", "grid", "group"}: updated = copy.deepcopy(element) children = element.get("children") if not isinstance(children, list): children = [] updated["children"] = _apply_template_v2_content_to_children( children, value, nested_content, direct_value=nested_direct_value, ) return updated return copy.deepcopy(element) def _template_v2_content_value( content: dict[str, Any], name: str, ) -> tuple[bool, Any]: for candidate in _template_v2_content_name_candidates(name): if candidate in content: return True, content[candidate] return False, None def _template_v2_content_name_candidates(name: str) -> list[str]: without_numeric_token = re.sub(r"_\d+(?=_|$)", "", name) without_prefix = ( without_numeric_token.split("_", 1)[1] if "_" in without_numeric_token else without_numeric_token ) candidates = [] for candidate in (name, without_numeric_token, without_prefix): if candidate and candidate not in candidates: candidates.append(candidate) return candidates def _apply_template_v2_content_to_children( children: list[Any], value: Any, content: Any, *, direct_value: bool = False, ) -> list[Any]: if isinstance(value, list) and children: return [ _apply_template_v2_content_to_element( children[min(index, len(children) - 1)], item, direct_value=True, ) for index, item in enumerate(value) ] return [ _apply_template_v2_content_to_element( child, content, direct_value=direct_value, ) for child in children ] def _apply_template_v2_content_value(element: dict[str, Any], value: Any) -> dict[str, Any]: element_type = element.get("type") if element_type == "text": return _apply_template_v2_text_content(element, value) if element_type == "image": return _apply_template_v2_image_content(element, value) if element_type == "text-list": return _apply_template_v2_text_list_content(element, value) if element_type == "table": return _apply_template_v2_table_content(element, value) if element_type == "chart": return _apply_template_v2_chart_content(element, value) return copy.deepcopy(element) def _read_template_v2_text(value: Any) -> Optional[str]: if isinstance(value, str): return value if isinstance(value, (int, float)) and not isinstance(value, bool): return str(value) if isinstance(value, dict): text = value.get("text") if isinstance(text, str): return text if isinstance(text, (int, float)) and not isinstance(text, bool): return str(text) return None def _apply_template_v2_text_content( element: dict[str, Any], value: Any, ) -> dict[str, Any]: text = _read_template_v2_text(value) if text is None or text == "": return copy.deepcopy(element) updated = copy.deepcopy(element) first_run = _first_template_v2_text_run(element.get("runs")) updated["runs"] = _template_v2_text_runs_from_markdown( text, first_run, fallback_font=element.get("font"), ) updated.pop("text", None) return updated def _apply_template_v2_image_content( element: dict[str, Any], value: Any, ) -> dict[str, Any]: if not isinstance(value, dict): return copy.deepcopy(element) url = None for key in ("image_url", "icon_url", "__image_url__", "__icon_url__", "url"): candidate = value.get(key) if isinstance(candidate, str) and candidate: url = candidate break if not url: return copy.deepcopy(element) updated = copy.deepcopy(element) updated["data"] = url prompt = _template_v2_asset_prompt(value, element.get("is_icon") is True) if prompt: updated["prompt"] = prompt return updated def _template_v2_asset_prompt(value: Any, is_icon: bool) -> Optional[str]: if not isinstance(value, dict): return None prompt_keys = ( ("icon_query", "__icon_query__", "query", "prompt") if is_icon else ("image_prompt", "__image_prompt__", "prompt", "query") ) for key in prompt_keys: prompt = value.get(key) if isinstance(prompt, str) and prompt.strip(): return prompt return None def _apply_template_v2_text_list_content( element: dict[str, Any], value: Any, ) -> dict[str, Any]: if not isinstance(value, list): return copy.deepcopy(element) existing_items = element.get("items") if not isinstance(existing_items, list): existing_items = [] items = [] for index, item in enumerate(value): text = _read_template_v2_text(item) if text is not None and text != "": existing_runs = ( existing_items[index] if index < len(existing_items) and isinstance(existing_items[index], list) else None ) first_run = ( existing_runs[0] if isinstance(existing_runs, list) and existing_runs and isinstance(existing_runs[0], dict) else {} ) items.append( _template_v2_text_runs_from_markdown( text, first_run, fallback_font=element.get("font"), ) ) updated = copy.deepcopy(element) updated["items"] = items return updated def _apply_template_v2_table_content( element: dict[str, Any], value: Any, ) -> dict[str, Any]: if not isinstance(value, dict): return copy.deepcopy(element) template_columns = element.get("columns") if not isinstance(template_columns, list): template_columns = [] template_rows = [ row for row in element.get("rows", []) if isinstance(row, list) ] generated_columns = [ _read_template_v2_table_text(item) for item in value.get("columns", []) ] if isinstance(value.get("columns"), list) else [] generated_rows = [ [_read_template_v2_table_text(cell) for cell in row] for row in value.get("rows", []) if isinstance(row, list) ] if isinstance(value.get("rows"), list) else [] fallback_row = template_rows[-1] if template_rows else template_columns updated = copy.deepcopy(element) updated["columns"] = ( _merge_template_v2_table_row_to_length( template_columns, generated_columns, is_header=True, ) if generated_columns else copy.deepcopy(template_columns) ) updated["rows"] = ( [ _merge_template_v2_table_row_to_length( template_rows[index] if index < len(template_rows) else fallback_row, row, is_header=False, ) for index, row in enumerate(generated_rows) ] if generated_rows else copy.deepcopy(template_rows) ) return updated def _merge_template_v2_table_row_to_length( template_cells: list[Any], generated_texts: list[Optional[str]], *, is_header: bool, ) -> list[Any]: fallback_cell = template_cells[-1] if template_cells else None return [ _replace_template_v2_table_cell_text( template_cells[index] if index < len(template_cells) else fallback_cell, text or "", is_header=is_header, ) for index, text in enumerate(generated_texts) ] def _replace_template_v2_table_cell_text( cell: Any, text: str, *, is_header: bool, ) -> dict[str, Any]: font = GENERATED_TABLE_HEADER_FONT if is_header else GENERATED_TABLE_TEXT_FONT if not isinstance(cell, dict): return { "color": GENERATED_TABLE_CELL_FILL, "stroke": GENERATED_TABLE_CELL_STROKE, "font": font, "runs": _template_v2_text_runs_from_markdown( text, {"font": font}, ), } updated = copy.deepcopy(cell) first_run = _first_template_v2_text_run(cell.get("runs")) run_font = first_run.get("font") if isinstance(first_run.get("font"), dict) else None next_font = run_font or cell.get("font") or font updated["color"] = cell.get("color") or cell.get("fill") or GENERATED_TABLE_CELL_FILL updated["stroke"] = cell.get("stroke") or GENERATED_TABLE_CELL_STROKE updated["font"] = cell.get("font") or next_font updated["runs"] = _template_v2_text_runs_from_markdown( text, first_run, fallback_font=next_font, ) updated.pop("text", None) updated.pop("fill", None) return updated def _first_template_v2_text_run(runs: Any) -> dict[str, Any]: if isinstance(runs, list) and runs and isinstance(runs[0], dict): return runs[0] return {} def _template_v2_text_runs_from_markdown( text: str, first_run: Any, *, fallback_font: Any = None, ) -> list[dict[str, Any]]: base_run = copy.deepcopy(first_run) if isinstance(first_run, dict) else {} parsed = _parse_template_v2_markdown_text(text) has_markdown_style = any(style for _parsed_text, style in parsed) base_run = _template_v2_base_run_for_markdown( base_run, fallback_font, strip_inline_emphasis=has_markdown_style, ) text_runs: list[dict[str, Any]] = [] for parsed_text, style in parsed: run = copy.deepcopy(base_run) run["text"] = parsed_text if style: font = run.get("font") run["font"] = { **(copy.deepcopy(font) if isinstance(font, dict) else {}), **style, } _append_template_v2_text_run(text_runs, run) if text_runs: return text_runs return [{**base_run, "text": " "}] def _template_v2_base_run_for_markdown( base_run: dict[str, Any], fallback_font: Any, *, strip_inline_emphasis: bool, ) -> dict[str, Any]: font = base_run.get("font") if isinstance(fallback_font, dict): merged_font = { **copy.deepcopy(fallback_font), **(copy.deepcopy(font) if isinstance(font, dict) else {}), } base_run["font"] = merged_font elif isinstance(font, dict): base_run["font"] = copy.deepcopy(font) if strip_inline_emphasis and isinstance(base_run.get("font"), dict): base_run["font"].pop("bold", None) base_run["font"].pop("italic", None) return base_run def _parse_template_v2_markdown_text( text: str, ) -> list[tuple[str, dict[str, bool]]]: parsed: list[tuple[str, dict[str, bool]]] = [] index = 0 while index < len(text): strong_delimiter = _template_v2_read_markdown_delimiter( text, index, TEMPLATE_V2_STRONG_MARKDOWN_DELIMITERS, ) if strong_delimiter: close = text.find(strong_delimiter, index + len(strong_delimiter)) if close > index + len(strong_delimiter): parsed.append( ( text[index + len(strong_delimiter) : close], {"bold": True}, ) ) index = close + len(strong_delimiter) continue emphasis_delimiter = _template_v2_read_markdown_delimiter( text, index, TEMPLATE_V2_EMPHASIS_MARKDOWN_DELIMITERS, ) if emphasis_delimiter: close = text.find(emphasis_delimiter, index + len(emphasis_delimiter)) if close > index + len(emphasis_delimiter): parsed.append( ( text[index + len(emphasis_delimiter) : close], {"italic": True}, ) ) index = close + len(emphasis_delimiter) continue next_index = _template_v2_next_markdown_delimiter_index(text, index + 1) parsed.append( ( text[index : len(text) if next_index == -1 else next_index], {}, ) ) index = len(text) if next_index == -1 else next_index return parsed def _template_v2_read_markdown_delimiter( text: str, index: int, delimiters: tuple[str, ...], ) -> Optional[str]: for delimiter in delimiters: if text.startswith(delimiter, index): return delimiter return None def _template_v2_next_markdown_delimiter_index(text: str, start: int) -> int: indexes = [ index for index in ( text.find(delimiter, start) for delimiter in TEMPLATE_V2_MARKDOWN_DELIMITERS ) if index != -1 ] return min(indexes) if indexes else -1 def _append_template_v2_text_run( text_runs: list[dict[str, Any]], run: dict[str, Any], ) -> None: text = run.get("text") if not isinstance(text, str) or text == "": return previous = text_runs[-1] if text_runs else None if isinstance(previous, dict): previous_style = {key: value for key, value in previous.items() if key != "text"} next_style = {key: value for key, value in run.items() if key != "text"} if previous_style == next_style and isinstance(previous.get("text"), str): previous["text"] += text return text_runs.append(run) def _read_template_v2_table_text(value: Any) -> Optional[str]: primitive_text = _read_template_v2_primitive_table_text(value) if primitive_text is not None: return primitive_text[:80] if not isinstance(value, dict): return None runs = value.get("runs") if isinstance(runs, list): run_text = "".join( run.get("text", "") for run in runs if isinstance(run, dict) and isinstance(run.get("text"), str) ) if run_text: return run_text[:80] for key in ("text", "value"): text = _read_template_v2_primitive_table_text(value.get(key)) if text is not None: return text[:80] return None def _read_template_v2_primitive_table_text(value: Any) -> Optional[str]: if isinstance(value, str): return value if isinstance(value, bool): return str(value).lower() if isinstance(value, (int, float)): return str(value) return None def _read_template_v2_data_labels(value: Any) -> Optional[str]: if value is True: return "top" if value is False or value is None: return None if isinstance(value, str): normalized = value.strip().lower() if normalized in {"base", "mid", "top", "outside"}: return normalized return None def _apply_template_v2_chart_content( element: dict[str, Any], value: Any, ) -> dict[str, Any]: updated = copy.deepcopy(element) updated.pop("data_labels_color", None) updated.pop("grid", None) if not isinstance(value, dict): return updated chart_type = value.get("chartType", value.get("chart_type")) if chart_type in { "area", "bar", "bubble", "donut", "horizontal_bar", "horizontal_stacked_bar", "line", "pie", "polar_area", "radar", "scatter", "stacked_bar", }: updated["chart_type"] = chart_type if isinstance(value.get("title"), str): updated["title"] = value["title"] if isinstance(value.get("categories"), list) and value["categories"]: updated["categories"] = value["categories"] if isinstance(value.get("series"), list) and value["series"]: updated["series"] = value["series"] colors = value.get("colors") if isinstance(colors, list) and colors: updated["colors"] = colors for source_key, target_key in ( ("axisColor", "axis_color"), ("axis_color", "axis_color"), ("gridColor", "grid_color"), ("grid_color", "grid_color"), ("xAxisTitle", "x_axis_title"), ("x_axis_title", "x_axis_title"), ("yAxisTitle", "y_axis_title"), ("y_axis_title", "y_axis_title"), ("source", "source"), ): if isinstance(value.get(source_key), str): updated[target_key] = value[source_key] for source_key, target_key in ( ("xAxis", "x_axis"), ("x_axis", "x_axis"), ("yAxis", "y_axis"), ("y_axis", "y_axis"), ("xAxisGrid", "x_axis_grid"), ("x_axis_grid", "x_axis_grid"), ("yAxisGrid", "y_axis_grid"), ("y_axis_grid", "y_axis_grid"), ): if isinstance(value.get(source_key), bool): updated[target_key] = value[source_key] for source_key in ("dataLabels", "data_labels"): if source_key in value: updated["data_labels"] = _read_template_v2_data_labels(value.get(source_key)) return updated def _get_presentation_stream_layout( presentation: PresentationModel, ) -> PresentationLayoutModel: if _is_template_v2_layout_payload(presentation.layout): layout_name = str(presentation.layout.get("name") or "template-v2") return _build_template_v2_layout_model( presentation.layout, layout_name=layout_name, ) return presentation.get_layout() async def _resolve_prepare_layout( layout: PresentationLayoutModel | str, sql_session: AsyncSession, ) -> tuple[dict[str, Any], PresentationLayoutModel, Optional[dict[str, str]]]: if isinstance(layout, PresentationLayoutModel): return layout.model_dump(mode="json"), layout, None template_id = layout.strip() if not template_id: raise HTTPException( status_code=400, detail="Template v2 layout id is required", ) template = await sql_session.get(TemplateV2, template_id) if not template: raise HTTPException(status_code=404, detail="Template v2 layout not found") layout_payload = copy.deepcopy(template.layouts) if not isinstance(layout_payload, dict): raise HTTPException( status_code=400, detail="Template v2 layout JSON must be an object", ) structure_layout = _build_template_v2_structure_layout(template, layout_payload) return ( layout_payload, structure_layout, _extract_template_v2_fonts_from_assets(template.assets), ) def _build_export_cookie_header(request: Request) -> Optional[str]: cookie_header = (request.headers.get("cookie") or "").strip() if cookie_header: return cookie_header session_token = get_session_token_from_request(request) if session_token: return f"{SESSION_COOKIE_NAME}={session_token}" username = getattr(request.state, "auth_username", None) if isinstance(username, str) and username.strip(): try: session_token = create_session_token(username.strip()) return f"{SESSION_COOKIE_NAME}={session_token}" except Exception: logger.exception( "[presentation.generate] failed to create export session token" ) return None @PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides]) async def get_all_presentations( version: Annotated[ Optional[PresentationVersion], Query(description="Only include presentations matching this version."), ] = None, sql_session: AsyncSession = Depends(get_async_session), ): query = ( select(PresentationModel, SlideModel) .join( SlideModel, (SlideModel.presentation == PresentationModel.id) & (SlideModel.index == 0), ) ) if version is not None: query = query.where(PresentationModel.version == version) query = query.order_by(PresentationModel.created_at.desc()) results = await sql_session.execute(query) rows = results.all() presentations_with_slides = [] for presentation, first_slide in rows: slides = [first_slide] fonts = await _resolve_presentation_fonts(presentation, slides, sql_session) presentations_with_slides.append( PresentationWithSlides( **_presentation_response_data(presentation), slides=slides, fonts=fonts, ) ) return presentations_with_slides @PRESENTATION_ROUTER.get("/{id}", response_model=PresentationDetailWithSlides) async def get_presentation( id: uuid.UUID, request: Request, sql_session: AsyncSession = Depends(get_async_session), ): presentation = await sql_session.get(PresentationModel, id) if not presentation: raise HTTPException(404, "Presentation not found") slides_result = await sql_session.scalars( select(SlideModel) .where(SlideModel.presentation == id) .order_by(SlideModel.index) ) slides = list(slides_result) fonts = await _resolve_presentation_fonts(presentation, slides, sql_session) merged_components = await _resolve_presentation_merged_components( presentation, slides, sql_session, ) return PresentationDetailWithSlides( **_presentation_response_data(presentation), slides=slides, fonts=fonts, merged_components=merged_components, ) @PRESENTATION_ROUTER.delete("/{id}", status_code=204) async def delete_presentation( id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session) ): presentation = await sql_session.get(PresentationModel, id) if not presentation: raise HTTPException(404, "Presentation not found") await sql_session.delete(presentation) await sql_session.commit() @PRESENTATION_ROUTER.post("/{id}/duplicate", response_model=PresentationWithSlides) async def duplicate_presentation( id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session) ): presentation = await sql_session.get(PresentationModel, id) if not presentation: raise HTTPException(404, "Presentation not found") slides = list( await sql_session.scalars( select(SlideModel) .where(SlideModel.presentation == id) .order_by(SlideModel.index) ) ) new_presentation = presentation.get_new_presentation() if new_presentation.title: new_presentation.title = f"{new_presentation.title} (Copy)" new_slides = [slide.get_new_slide(new_presentation.id) for slide in slides] sql_session.add(new_presentation) sql_session.add_all(new_slides) await sql_session.commit() await sql_session.refresh(new_presentation) return PresentationWithSlides( **_presentation_response_data(new_presentation), slides=new_slides, fonts=await _resolve_presentation_fonts( new_presentation, new_slides, sql_session, ), ) @PRESENTATION_ROUTER.post("/create", response_model=PresentationModel) async def create_presentation( content: Annotated[str, Body()], n_slides: Annotated[Optional[int], Body()] = None, language: Annotated[Optional[str], Body()] = None, file_paths: Annotated[Optional[List[str]], Body()] = None, tone: Annotated[Tone, Body()] = Tone.DEFAULT, verbosity: Annotated[Verbosity, Body()] = Verbosity.STANDARD, instructions: Annotated[Optional[str], Body()] = None, include_table_of_contents: Annotated[bool, Body()] = False, include_title_slide: Annotated[bool, Body()] = True, web_search: Annotated[bool, Body()] = False, sql_session: AsyncSession = Depends(get_async_session), ): if n_slides is not None and n_slides < 1: raise HTTPException( status_code=400, detail="Number of slides must be greater than 0", ) if n_slides is not None and n_slides > MAX_NUMBER_OF_SLIDES: raise HTTPException( status_code=400, detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}", ) if include_table_of_contents and n_slides is not None and n_slides < 3: raise HTTPException( status_code=400, detail="Number of slides cannot be less than 3 if table of contents is included", ) presentation_id = uuid.uuid4() language_to_store = (language or "").strip() validated_file_paths = ( TEMP_FILE_SERVICE.resolve_existing_temp_paths(file_paths) if file_paths else None ) # DB schema stores an int; 0 is used as internal marker for auto slide count. n_slides_to_store = n_slides if n_slides is not None else 0 presentation = PresentationModel( id=presentation_id, version=PresentationVersion.V2_STANDARD, content=content, n_slides=n_slides_to_store, language=language_to_store, file_paths=validated_file_paths, tone=tone.value, verbosity=verbosity.value, instructions=instructions, include_table_of_contents=include_table_of_contents, include_title_slide=include_title_slide, web_search=web_search, ) sql_session.add(presentation) await sql_session.commit() search_route, actual_search_provider = get_web_search_route() logger.info( "Created presentation: id=%s web_search_enabled=%s selected_web_search_provider=%s " "web_search_route=%s actual_web_search_provider=%s", presentation_id, web_search, get_selected_web_search_provider().value, search_route, ( actual_search_provider.value if actual_search_provider else ("model-native" if search_route == "native" else "none") ), ) return presentation @PRESENTATION_ROUTER.post("/prepare", response_model=PresentationModel) async def prepare_presentation( presentation_id: Annotated[uuid.UUID, Body()], outlines: Annotated[List[SlideOutlineModel], Body()], layout: Annotated[PresentationLayoutModel | str, Body()], title: Annotated[Optional[str], Body()] = None, sql_session: AsyncSession = Depends(get_async_session), ): if not outlines: raise HTTPException(status_code=400, detail="Outlines are required") if len(outlines) > MAX_NUMBER_OF_SLIDES: raise HTTPException( status_code=400, detail=f"Number of outlines cannot be greater than {MAX_NUMBER_OF_SLIDES}", ) presentation = await sql_session.get(PresentationModel, presentation_id) if not presentation: raise HTTPException(status_code=404, detail="Presentation not found") presentation_outline_model = PresentationOutlineModel(slides=outlines) layout_payload, structure_layout, template_fonts = await _resolve_prepare_layout( layout, sql_session ) total_slide_layouts = _layout_count(layout_payload) if total_slide_layouts < 1: raise HTTPException( status_code=400, detail="Layout must contain at least one slide layout", ) total_outlines = len(outlines) if structure_layout.ordered: presentation_structure = structure_layout.to_presentation_structure() else: presentation_structure: PresentationStructureModel = ( await generate_presentation_structure( presentation_outline=presentation_outline_model, presentation_layout=structure_layout, instructions=presentation.instructions, ) ) presentation_structure.slides = presentation_structure.slides[: len(outlines)] for index in range(total_outlines): random_slide_index = random.randint(0, total_slide_layouts - 1) if index >= total_outlines: presentation_structure.slides.append(random_slide_index) continue if presentation_structure.slides[index] >= total_slide_layouts: presentation_structure.slides[index] = random_slide_index if presentation.include_table_of_contents: n_toc_slides = get_no_of_toc_required_for_n_outlines( n_outlines=total_outlines, title_slide=presentation.include_title_slide, target_total_slides=(presentation.n_slides if presentation.n_slides > 0 else None), ) toc_slide_layout_index = select_toc_or_list_slide_layout_index(structure_layout) _insert_toc_layouts( presentation_structure, n_toc_slides, presentation.include_title_slide, toc_slide_layout_index, ) if toc_slide_layout_index != -1 and n_toc_slides > 0: presentation_outline_model = get_presentation_outline_model_with_toc( outline=presentation_outline_model, n_toc_slides=n_toc_slides, title_slide=presentation.include_title_slide, ) sql_session.add(presentation) presentation.outlines = presentation_outline_model.model_dump(mode="json") presentation.title = title or presentation.title # Final slide generation should follow the reviewed outline text. The # original upload language can be stale after outline-page chat edits such # as "convert these to Chinese". presentation.language = "" presentation.layout = layout_payload presentation.fonts = template_fonts presentation.set_structure(presentation_structure) await sql_session.commit() await MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines( presentation.id, presentation.outlines, ) return presentation @PRESENTATION_ROUTER.get("/stream/{id}", response_model=PresentationDetailWithSlides) async def stream_presentation( id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session) ): presentation = await sql_session.get(PresentationModel, id) if not presentation: raise HTTPException(status_code=404, detail="Presentation not found") if not presentation.structure: raise HTTPException( status_code=400, detail="Presentation not prepared for stream", ) if not presentation.outlines: raise HTTPException( status_code=400, detail="Outlines can not be empty", ) try: structure = presentation.get_structure() layout = _get_presentation_stream_layout(presentation) outline = presentation.get_presentation_outline() except Exception as exc: raise HTTPException( status_code=400, detail="Presentation has invalid generated data", ) from exc if not layout.slides: raise HTTPException(status_code=400, detail="Presentation layout has no slides") if len(structure.slides) > len(outline.slides): raise HTTPException( status_code=400, detail="Presentation structure has more slides than outlines", ) invalid_layout_index = next( ( slide_layout_index for slide_layout_index in structure.slides if slide_layout_index < 0 or slide_layout_index >= len(layout.slides) ), None, ) if invalid_layout_index is not None: raise HTTPException( status_code=400, detail="Presentation structure contains an invalid slide layout", ) image_generation_service = ImageGenerationService(get_images_directory()) async def inner(): icon_weight = layout.icon_weight image_urls_for_slides = get_images_for_slides_from_outline(outline.slides) async_assets_generation_tasks: List[asyncio.Task] = [] asset_events: asyncio.Queue = asyncio.Queue() asset_warnings_by_slide: dict[int, list[dict]] = {} async def notify_slide_assets_ready(slide_index: int, asset_task: asyncio.Task): try: await asset_task except Exception: logger.exception( "Slide asset generation failed: presentation_id=%s slide_index=%s", id, slide_index, ) asset_warnings_by_slide.setdefault(slide_index, []).append( { "type": "asset_generation_failed", "message": "Some slide assets could not be generated.", } ) finally: await asset_events.put(slide_index) slides: List[SlideModel] = [] yield SSEResponse( event="response", data=json.dumps({"type": "chunk", "chunk": '{ "slides": [ '}), ).to_string() yielded_slide_asset_sse_count = 0 for i, slide_layout_index in enumerate(structure.slides): slide_layout = layout.slides[slide_layout_index] try: slide_content = await get_slide_content_from_type_and_outline( slide_layout, outline.slides[i], presentation.language, presentation.tone, presentation.verbosity, presentation.instructions, ) except HTTPException as e: yield SSEErrorResponse(detail=e.detail).to_string() return slide = SlideModel( presentation=id, layout_group=layout.name, layout=slide_layout.id, index=i, speaker_note=slide_content.get("__speaker_note__", ""), content=slide_content, ui=_template_v2_slide_ui(presentation.layout, slide_layout.id), ) slides.append(slide) # This will mutate slide and add placeholder assets process_slide_add_placeholder_assets(slide) slide.ui = _apply_template_v2_content_to_ui(slide.ui, slide.content) # This will mutate slide - start task immediately so it runs in parallel with next slide LLM generation asset_warnings_by_slide[i] = [] asset_task = asyncio.create_task( process_slide_and_fetch_assets( image_generation_service, slide, outline_image_urls=( image_urls_for_slides[i] if i < len(image_urls_for_slides) else None ), icon_weight=icon_weight, allow_image_fallback=True, image_warnings=asset_warnings_by_slide[i], ) ) async_assets_generation_tasks.append(asset_task) asyncio.create_task(notify_slide_assets_ready(i, asset_task)) yield SSEResponse( event="response", data=json.dumps({"type": "chunk", "chunk": slide.model_dump_json()}), ).to_string() while True: try: done_idx = asset_events.get_nowait() except asyncio.QueueEmpty: break slides[done_idx].ui = _apply_template_v2_content_to_ui( slides[done_idx].ui, slides[done_idx].content, ) yielded_slide_asset_sse_count += 1 yield SSEResponse( event="response", data=json.dumps( { "type": "slide_assets", "slide_index": done_idx, "slide": slides[done_idx].model_dump(mode="json"), "warnings": asset_warnings_by_slide.get(done_idx, []), } ), ).to_string() yield SSEResponse( event="response", data=json.dumps({"type": "chunk", "chunk": " ] }"}), ).to_string() while yielded_slide_asset_sse_count < len(slides): done_idx = await asset_events.get() slides[done_idx].ui = _apply_template_v2_content_to_ui( slides[done_idx].ui, slides[done_idx].content, ) yielded_slide_asset_sse_count += 1 yield SSEResponse( event="response", data=json.dumps( { "type": "slide_assets", "slide_index": done_idx, "slide": slides[done_idx].model_dump(mode="json"), "warnings": asset_warnings_by_slide.get(done_idx, []), } ), ).to_string() generated_assets_lists = await asyncio.gather( *async_assets_generation_tasks, return_exceptions=True, ) generated_assets = [] for assets_list in generated_assets_lists: if isinstance(assets_list, Exception): logger.error( "Slide asset generation failed during final collection: %s", assets_list, ) continue generated_assets.extend(assets_list) for slide in slides: slide.ui = _apply_template_v2_content_to_ui(slide.ui, slide.content) # Moved this here to make sure new slides are generated before deleting the old ones await sql_session.execute( delete(SlideModel).where(SlideModel.presentation == id) ) await sql_session.commit() sql_session.add(presentation) sql_session.add_all(slides) sql_session.add_all(generated_assets) await sql_session.commit() response = PresentationDetailWithSlides( **_presentation_response_data(presentation), slides=slides, fonts=await _resolve_presentation_fonts(presentation, slides, sql_session), merged_components=await _resolve_presentation_merged_components( presentation, slides, sql_session, ), ) yield SSECompleteResponse( key="presentation", value=response.model_dump(mode="json"), ).to_string() async def rollback_stream_session(): await sql_session.rollback() return StreamingResponse( safe_sse_stream( inner(), logger=logger, error_detail="Failed to generate presentation slides. Please try again.", on_error=rollback_stream_session, ), media_type="text/event-stream", ) @PRESENTATION_ROUTER.patch("/update", response_model=PresentationDetailWithSlides) async def update_presentation( id: Annotated[uuid.UUID, Body()], n_slides: Annotated[Optional[int], Body()] = None, title: Annotated[Optional[str], Body()] = None, theme: Annotated[Optional[dict], Body()] = None, slides: Annotated[Optional[List[SlideModel]], Body()] = None, sql_session: AsyncSession = Depends(get_async_session), ): presentation = await sql_session.get(PresentationModel, id) if not presentation: raise HTTPException(status_code=404, detail="Presentation not found") presentation_update_dict = {} if n_slides is not None: if n_slides < 1: raise HTTPException( status_code=400, detail="Number of slides must be greater than 0", ) if n_slides > MAX_NUMBER_OF_SLIDES: raise HTTPException( status_code=400, detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}", ) presentation_update_dict["n_slides"] = n_slides if title: presentation_update_dict["title"] = title if theme or theme is None: presentation_update_dict["theme"] = theme if presentation_update_dict: presentation.sqlmodel_update(presentation_update_dict) if slides: if len(slides) > MAX_NUMBER_OF_SLIDES: raise HTTPException( status_code=400, detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}", ) # Just to make sure id is UUID for slide in slides: slide.presentation = uuid.UUID(slide.presentation) slide.id = uuid.UUID(slide.id) await sql_session.execute( delete(SlideModel).where(SlideModel.presentation == presentation.id) ) sql_session.add_all(slides) await sql_session.commit() response_slides = slides or [] fonts = await _resolve_presentation_fonts( presentation, response_slides, sql_session, ) merged_components = await _resolve_presentation_merged_components( presentation, response_slides, sql_session, ) return PresentationDetailWithSlides( **_presentation_response_data(presentation), slides=response_slides, fonts=fonts, merged_components=merged_components, ) async def check_if_api_request_is_valid( request: GeneratePresentationRequest, sql_session: AsyncSession = Depends(get_async_session), ) -> Tuple[uuid.UUID,]: presentation_id = uuid.uuid4() print(f"Presentation ID: {presentation_id}") # Making sure either content, slides markdown or files is provided if not (request.content or request.slides_markdown or request.files): raise HTTPException( status_code=400, detail="Either content or slides markdown or files is required to generate presentation", ) if request.n_slides is not None and request.n_slides <= 0: raise HTTPException( status_code=400, detail="Number of slides must be greater than 0", ) if request.n_slides is not None and request.n_slides > MAX_NUMBER_OF_SLIDES: raise HTTPException( status_code=400, detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}", ) if ( request.slides_markdown is not None and len(request.slides_markdown) > MAX_NUMBER_OF_SLIDES ): raise HTTPException( status_code=400, detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}", ) if ( request.include_table_of_contents and request.n_slides is not None and request.n_slides < 3 ): raise HTTPException( status_code=400, detail="Number of slides cannot be less than 3 if table of contents is included", ) # Checking if template is valid if request.template not in DEFAULT_TEMPLATES: template_v2 = await _resolve_requested_template_v2( request.template, sql_session, ) if not template_v2: raise HTTPException( status_code=400, detail="Template not found. Please use a valid template.", ) request.template = f"template-v2-{template_v2.id}" return (presentation_id,) return (presentation_id,) async def generate_presentation_handler( request: GeneratePresentationRequest, presentation_id: uuid.UUID, async_status: Optional[AsyncTaskModel], export_cookie_header: Optional[str] = None, sql_session: AsyncSession = Depends(get_async_session), ): try: using_slides_markdown = False language_to_use = (request.language or "").strip() or None additional_context = "" if request.slides_markdown: using_slides_markdown = True if len(request.slides_markdown) > MAX_NUMBER_OF_SLIDES: raise HTTPException( status_code=400, detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}", ) request.n_slides = len(request.slides_markdown) if not using_slides_markdown: # Updating async status if async_status: async_status.message = "Generating presentation outlines" async_status.data = _presentation_task_progress_data( created_slides=0, remaining_slides=_requested_slide_count(request), ) async_status.updated_at = datetime.now() sql_session.add(async_status) await sql_session.commit() if request.files: documents_loader = DocumentsLoader( file_paths=request.files, presentation_language=request.language, ) await documents_loader.load_documents() documents = documents_loader.documents if documents: additional_context = "\n\n".join(documents) # Finding number of slides to generate by considering table of contents n_slides_to_generate = request.n_slides if request.include_table_of_contents and request.n_slides is not None: n_slides_to_generate = ( get_no_of_outlines_to_generate_for_n_slides( n_slides=request.n_slides, toc=True, title_slide=request.include_title_slide, ) ) outline_messages = get_outline_messages( request.content, n_slides_to_generate, language_to_use, additional_context, request.tone.value, request.verbosity.value, request.instructions, request.include_title_slide, request.include_table_of_contents, ) await MEM0_PRESENTATION_MEMORY_SERVICE.store_generation_context( presentation_id=presentation_id, system_prompt=( message_content_to_text(outline_messages[0].content) if len(outline_messages) > 0 else None ), user_prompt=( message_content_to_text(outline_messages[1].content) if len(outline_messages) > 1 else None ), extracted_document_text=additional_context, source_content=request.content, instructions=request.instructions, ) presentation_outlines_text = "" async for chunk in generate_ppt_outline( request.content, n_slides_to_generate, language_to_use, additional_context, request.tone.value, request.verbosity.value, request.instructions, request.include_title_slide, request.web_search, request.include_table_of_contents, ): if isinstance(chunk, HTTPException): raise chunk presentation_outlines_text += chunk try: presentation_outlines_json = dict( dirtyjson.loads(presentation_outlines_text) ) except Exception: traceback.print_exc() raise HTTPException( status_code=400, detail="Failed to generate presentation outlines. Please try again.", ) presentation_outlines = PresentationOutlineModel( **normalize_outline_payload( presentation_outlines_json, MAX_NUMBER_OF_SLIDES, ) ) if ( n_slides_to_generate is not None and len(presentation_outlines.slides) != n_slides_to_generate ): raise HTTPException( status_code=400, detail=( "Failed to generate presentation outlines with requested " "number of slides. Please try again." ), ) total_outlines = len(presentation_outlines.slides) else: # Setting outlines to slides markdown presentation_outlines = PresentationOutlineModel( slides=[ SlideOutlineModel(content=slide) for slide in request.slides_markdown ] ) total_outlines = len(request.slides_markdown) await MEM0_PRESENTATION_MEMORY_SERVICE.store_generation_context( presentation_id=presentation_id, system_prompt=None, user_prompt=None, extracted_document_text=None, source_content=request.content, instructions=request.instructions, ) await MEM0_PRESENTATION_MEMORY_SERVICE.store_generated_outlines( presentation_id, presentation_outlines.model_dump(mode="json"), ) # Updating async status if async_status: async_status.message = "Selecting layout for each slide" async_status.updated_at = datetime.now() sql_session.add(async_status) await sql_session.commit() print("-" * 40) print(f"Generated {total_outlines} outlines for the presentation") logger.info( "[presentation.generate] loading layout template=%r presentation_id=%s", request.template, presentation_id, ) ( layout_payload, layout_model, template_fonts, is_template_v2, ) = await _resolve_generation_layout(request.template, sql_session) logger.info( "[presentation.generate] layout ready template=%r slides=%d ordered=%s icon_weight=%s", request.template, len(layout_model.slides), layout_model.ordered, layout_model.icon_weight, ) total_slide_layouts = len(layout_model.slides) # Generate Structure if layout_model.ordered: presentation_structure = layout_model.to_presentation_structure() else: presentation_structure: PresentationStructureModel = ( await generate_presentation_structure( presentation_outlines, layout_model, request.instructions, using_slides_markdown, ) ) presentation_structure.slides = presentation_structure.slides[:total_outlines] for index in range(total_outlines): random_slide_index = random.randint(0, total_slide_layouts - 1) if index >= total_outlines: presentation_structure.slides.append(random_slide_index) continue if presentation_structure.slides[index] >= total_slide_layouts: presentation_structure.slides[index] = random_slide_index should_include_toc = ( request.include_table_of_contents and not using_slides_markdown ) if should_include_toc: n_toc_slides = get_no_of_toc_required_for_n_outlines( n_outlines=total_outlines, title_slide=request.include_title_slide, target_total_slides=request.n_slides, ) toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout_model) _insert_toc_layouts( presentation_structure, n_toc_slides, request.include_title_slide, toc_slide_layout_index, ) if toc_slide_layout_index != -1 and n_toc_slides > 0: presentation_outlines = get_presentation_outline_model_with_toc( outline=presentation_outlines, n_toc_slides=n_toc_slides, title_slide=request.include_title_slide, ) final_n_slides = request.n_slides if final_n_slides is None: final_n_slides = len(presentation_outlines.slides) # Create PresentationModel presentation = PresentationModel( id=presentation_id, version=( PresentationVersion.V2_STANDARD if is_template_v2 else PresentationVersion.V1_STANDARD ), content=request.content, n_slides=final_n_slides, language=language_to_use or "", title=get_presentation_title_from_presentation_outline( presentation_outlines ), outlines=presentation_outlines.model_dump(), layout=layout_payload, structure=presentation_structure.model_dump(), tone=request.tone.value, verbosity=request.verbosity.value, instructions=request.instructions, fonts=template_fonts, ) # Updating async status if async_status: async_status.message = "Generating slides" async_status.data = _presentation_task_progress_data( created_slides=0, remaining_slides=final_n_slides or 0, ) async_status.updated_at = datetime.now() sql_session.add(async_status) await sql_session.commit() image_generation_service = ImageGenerationService(get_images_directory()) async_assets_generation_tasks = [] image_warnings: List[dict] = [] # 7. Generate slide content concurrently (batched), then build slides and fetch assets slides: List[SlideModel] = [] slide_layout_indices = presentation_structure.slides slide_layouts = [layout_model.slides[idx] for idx in slide_layout_indices] total_slides_to_create = len(slide_layouts) # Schedule slide content generation and asset fetching in batches of 10 batch_size = 10 for start in range(0, len(slide_layouts), batch_size): end = min(start + batch_size, len(slide_layouts)) print(f"Generating slides from {start} to {end}") # Generate contents for this batch concurrently content_tasks = [ get_slide_content_from_type_and_outline( slide_layouts[i], presentation_outlines.slides[i], language_to_use, request.tone.value, request.verbosity.value, request.instructions, ) for i in range(start, end) ] batch_contents: List[dict] = await asyncio.gather(*content_tasks) # Build slides for this batch batch_slides: List[SlideModel] = [] for offset, slide_content in enumerate(batch_contents): i = start + offset slide_layout = slide_layouts[i] slide = SlideModel( presentation=presentation_id, layout_group=layout_model.name, layout=slide_layout.id, index=i, speaker_note=slide_content.get("__speaker_note__"), content=slide_content, ui=( _template_v2_slide_ui(layout_payload, slide_layout.id) if is_template_v2 else None ), ) slides.append(slide) batch_slides.append(slide) if async_status: async_status.data = _presentation_task_progress_data( created_slides=len(slides), remaining_slides=total_slides_to_create - len(slides), ) async_status.updated_at = datetime.now() sql_session.add(async_status) await sql_session.commit() if using_slides_markdown: image_urls_for_batch = get_images_for_slides_from_outline( presentation_outlines.slides[start:end] ) else: image_urls_for_batch = [[] for _ in batch_slides] # Start asset fetch tasks immediately so they run in parallel with next batch's LLM calls asset_tasks = [ asyncio.create_task( process_slide_and_fetch_assets( image_generation_service, slide, outline_image_urls=image_urls_for_batch[offset], icon_weight=layout_model.icon_weight, allow_image_fallback=True, image_warnings=image_warnings, ) ) for offset, slide in enumerate(batch_slides) ] async_assets_generation_tasks.extend(asset_tasks) if async_status: async_status.message = "Fetching assets for slides" async_status.data = _presentation_task_progress_data( created_slides=len(slides), remaining_slides=0, ) async_status.updated_at = datetime.now() sql_session.add(async_status) await sql_session.commit() # Run all asset tasks concurrently while batches may still be generating content generated_assets_list = await asyncio.gather(*async_assets_generation_tasks) generated_assets = [] for assets_list in generated_assets_list: generated_assets.extend(assets_list) for warning in image_warnings: logger.warning( "Slide image generation warning: presentation_id=%s detail=%s", presentation_id, warning.get("detail"), ) if is_template_v2: for slide in slides: _hydrate_template_v2_slide_ui(slide, layout_payload) # 8. Save PresentationModel and Slides sql_session.add(presentation) sql_session.add_all(slides) sql_session.add_all(generated_assets) await sql_session.commit() if async_status: async_status.message = "Exporting presentation" async_status.updated_at = datetime.now() sql_session.add(async_status) # 9. Export presentation_and_path = await export_presentation( presentation_id, presentation.title or str(uuid.uuid4()), request.export_as, cookie_header=export_cookie_header, ) response = PresentationPathAndEditPath( **presentation_and_path.model_dump(), edit_path=f"/presentation?id={presentation_id}", ) if async_status: async_status.message = "Presentation generation completed" async_status.status = "completed" async_status.data = _presentation_task_progress_data( created_slides=len(slides), remaining_slides=0, ) async_status.updated_at = datetime.now() sql_session.add(async_status) await sql_session.commit() # Triggering webhook on success CONCURRENT_SERVICE.run_task( None, WebhookService.send_webhook, WebhookEvent.PRESENTATION_GENERATION_COMPLETED, response.model_dump(mode="json"), ) return response except Exception as e: if not isinstance(e, HTTPException): traceback.print_exc() e = HTTPException(status_code=500, detail="Presentation generation failed") api_error_model = APIErrorModel.from_exception(e) # Triggering webhook on failure CONCURRENT_SERVICE.run_task( None, WebhookService.send_webhook, WebhookEvent.PRESENTATION_GENERATION_FAILED, api_error_model.model_dump(mode="json"), ) if async_status: async_status.status = "error" async_status.message = "Presentation generation failed" async_status.updated_at = datetime.now() async_status.error = api_error_model.model_dump(mode="json") sql_session.add(async_status) await sql_session.commit() else: raise e @PRESENTATION_ROUTER.post("/generate", response_model=PresentationPathAndEditPath) async def generate_presentation_sync( request_http: Request, request: GeneratePresentationRequest, sql_session: AsyncSession = Depends(get_async_session), ): try: (presentation_id,) = await check_if_api_request_is_valid(request, sql_session) return await generate_presentation_handler( request, presentation_id, None, export_cookie_header=_build_export_cookie_header(request_http), sql_session=sql_session, ) except HTTPException: raise except Exception: traceback.print_exc() raise HTTPException(status_code=500, detail="Presentation generation failed") async def _run_generate_presentation_task( request: GeneratePresentationRequest, presentation_id: uuid.UUID, task_id: str, export_cookie_header: Optional[str], ) -> None: async with async_session_maker() as sql_session: async_status = await sql_session.get(AsyncTaskModel, task_id) if not async_status: logger.warning( "[presentation.generate.async] task missing task_id=%s", task_id, ) return async_status.status = "processing" async_status.message = "Starting presentation generation" async_status.data = _presentation_task_progress_data( created_slides=0, remaining_slides=_requested_slide_count(request), ) async_status.updated_at = datetime.now() sql_session.add(async_status) await sql_session.commit() await generate_presentation_handler( request, presentation_id, async_status=async_status, export_cookie_header=export_cookie_header, sql_session=sql_session, ) @PRESENTATION_ROUTER.post("/generate/async", response_model=AsyncTaskModel) async def generate_presentation_async( request_http: Request, request: GeneratePresentationRequest, background_tasks: BackgroundTasks, sql_session: AsyncSession = Depends(get_async_session), ): try: (presentation_id,) = await check_if_api_request_is_valid(request, sql_session) async_status = AsyncTaskModel( type=ASYNC_TASK_TYPE_PRESENTATION_GENERATE, status="pending", message="Queued for generation", data=_presentation_task_progress_data( created_slides=0, remaining_slides=_requested_slide_count(request), ), ) sql_session.add(async_status) await sql_session.commit() await sql_session.refresh(async_status) background_tasks.add_task( _run_generate_presentation_task, request, presentation_id, async_status.id, _build_export_cookie_header(request_http), ) return async_status except Exception as e: if not isinstance(e, HTTPException): print(e) e = HTTPException(status_code=500, detail="Presentation generation failed") raise e @PRESENTATION_ROUTER.get("/status/{id}", response_model=AsyncTaskModel) async def check_async_presentation_generation_status( id: str = Path(description="ID of the presentation generation task"), sql_session: AsyncSession = Depends(get_async_session), ): status = await sql_session.get(AsyncTaskModel, id) if not status: raise HTTPException( status_code=404, detail="No presentation generation task found" ) return status @PRESENTATION_ROUTER.post("/edit", response_model=PresentationPathAndEditPath) async def edit_presentation_with_new_content( request_http: Request, data: Annotated[EditPresentationRequest, Body()], sql_session: AsyncSession = Depends(get_async_session), ): presentation = await sql_session.get(PresentationModel, data.presentation_id) if not presentation: raise HTTPException(status_code=404, detail="Presentation not found") slides = await sql_session.scalars( select(SlideModel).where(SlideModel.presentation == data.presentation_id) ) new_slides = [] slides_to_delete = [] for each_slide in slides: updated_content = None new_slide_data = list( filter(lambda x: x.index == each_slide.index, data.slides) ) if new_slide_data: updated_content = deep_update(each_slide.content, new_slide_data[0].content) new_slide = each_slide.get_new_slide(presentation.id, updated_content) _hydrate_template_v2_slide_ui(new_slide, presentation.layout) new_slides.append(new_slide) slides_to_delete.append(each_slide.id) await sql_session.execute( delete(SlideModel).where(SlideModel.id.in_(slides_to_delete)) ) sql_session.add_all(new_slides) await sql_session.commit() presentation_and_path = await export_presentation( presentation.id, presentation.title or str(uuid.uuid4()), data.export_as, cookie_header=_build_export_cookie_header(request_http), ) return PresentationPathAndEditPath( **presentation_and_path.model_dump(), edit_path=f"/presentation?id={presentation.id}", ) @PRESENTATION_ROUTER.post("/derive", response_model=PresentationPathAndEditPath) async def derive_presentation_from_existing_one( request_http: Request, data: Annotated[EditPresentationRequest, Body()], sql_session: AsyncSession = Depends(get_async_session), ): presentation = await sql_session.get(PresentationModel, data.presentation_id) if not presentation: raise HTTPException(status_code=404, detail="Presentation not found") slides = await sql_session.scalars( select(SlideModel).where(SlideModel.presentation == data.presentation_id) ) new_presentation = presentation.get_new_presentation() new_slides = [] for each_slide in slides: updated_content = None new_slide_data = list( filter(lambda x: x.index == each_slide.index, data.slides) ) if new_slide_data: updated_content = deep_update(each_slide.content, new_slide_data[0].content) new_slide = each_slide.get_new_slide(new_presentation.id, updated_content) _hydrate_template_v2_slide_ui(new_slide, new_presentation.layout) new_slides.append(new_slide) sql_session.add(new_presentation) sql_session.add_all(new_slides) await sql_session.commit() presentation_and_path = await export_presentation( new_presentation.id, new_presentation.title or str(uuid.uuid4()), data.export_as, cookie_header=_build_export_cookie_header(request_http), ) return PresentationPathAndEditPath( **presentation_and_path.model_dump(), edit_path=f"/presentation?id={new_presentation.id}", )