Merge pull request #274 from presenton/refactor/images-and-ppt-endpoints

refactor/images and ppt endpoints
This commit is contained in:
Saurav Niraula 2025-09-07 22:33:48 +05:45 committed by GitHub
commit 64daae1065
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 596 additions and 249 deletions

View file

@ -9,9 +9,8 @@ from services.database import get_async_session
from services.image_generation_service import ImageGenerationService
from utils.asset_directory_utils import get_images_directory
import os
from utils.asset_directory_utils import get_uploads_directory
import uuid
from services.image_upload_service import ImageUploadService
from utils.file_utils import get_file_name_with_random_uuid
IMAGES_ROUTER = APIRouter(prefix="/images", tags=["Images"])
@ -38,51 +37,69 @@ async def generate_image(
async def get_generated_images(sql_session: AsyncSession = Depends(get_async_session)):
try:
images = await sql_session.scalars(
select(ImageAsset).where(ImageAsset.is_uploaded == False).order_by(ImageAsset.created_at.desc())
select(ImageAsset)
.where(ImageAsset.is_uploaded == False)
.order_by(ImageAsset.created_at.desc())
)
return images
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve generated images: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Failed to retrieve generated images: {str(e)}"
)
@IMAGES_ROUTER.post("/upload-image")
async def upload_image(file: UploadFile = File(...), sql_session: AsyncSession = Depends(get_async_session)):
@IMAGES_ROUTER.post("/upload")
async def upload_image(
file: UploadFile = File(...), sql_session: AsyncSession = Depends(get_async_session)
):
try:
service = ImageUploadService(get_uploads_directory())
image_asset = await service.upload_image(file)
new_filename = get_file_name_with_random_uuid(file)
image_path = os.path.join(
get_images_directory(), os.path.basename(new_filename)
)
with open(image_path, "wb") as f:
f.write(await file.read())
image_asset = ImageAsset(path=image_path, is_uploaded=True)
sql_session.add(image_asset)
await sql_session.commit()
return {
"message": "Image uploaded successfully",
"path": image_asset.path,
"id": str(image_asset.id),
}
return image_asset
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to upload image: {str(e)}")
@IMAGES_ROUTER.get("/uploaded", response_model=List[ImageAsset])
async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_session)):
try:
images = await sql_session.scalars(
select(ImageAsset).where(ImageAsset.is_uploaded == True).order_by(ImageAsset.created_at.desc())
select(ImageAsset)
.where(ImageAsset.is_uploaded == True)
.order_by(ImageAsset.created_at.desc())
)
return images
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve uploaded images: {str(e)}")
@IMAGES_ROUTER.delete("/uploaded-image/{image_id}")
async def delete_image(image_id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)):
raise HTTPException(
status_code=500, detail=f"Failed to retrieve uploaded images: {str(e)}"
)
@IMAGES_ROUTER.delete("/{id}", status_code=204)
async def delete_uploaded_image_by_id(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
try:
# Fetch the asset to get its actual file path
image = await sql_session.get(ImageAsset, image_id)
image = await sql_session.get(ImageAsset, id)
if not image:
raise HTTPException(status_code=404, detail="Image not found")
service = ImageUploadService(get_uploads_directory())
await service.delete_image(image.path)
os.remove(image.path)
await sql_session.delete(image)
await sql_session.commit()
return {"message": "Image deleted successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete image: {str(e)}")

View file

@ -1,5 +1,6 @@
import asyncio
import json
import math
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
@ -16,17 +17,17 @@ from models.sse_response import (
from services.temp_file_service import TEMP_FILE_SERVICE
from services.database import get_async_session
from services.documents_loader import DocumentsLoader
from services.score_based_chunker import ScoreBasedChunker
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
from utils.ppt_utils import get_presentation_title_from_outlines
OUTLINES_ROUTER = APIRouter(prefix="/outlines", tags=["Outlines"])
@OUTLINES_ROUTER.get("/stream")
@OUTLINES_ROUTER.get("/stream/{id}")
async def stream_outlines(
presentation_id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
presentation = await sql_session.get(PresentationModel, presentation_id)
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
@ -38,79 +39,63 @@ async def stream_outlines(
status="Generating presentation outlines..."
).to_string()
presentation_outlines = None
additional_context = ""
if presentation.file_paths:
documents_loader = DocumentsLoader(file_paths=presentation.file_paths)
await documents_loader.load_documents(temp_dir)
documents = documents_loader.documents
if documents and len(documents) == 1:
additional_context = documents[0]
chunker = ScoreBasedChunker()
try:
chunks = await chunker.get_n_chunks(
documents[0], presentation.n_slides
)
presentation_outlines = PresentationOutlineModel(
slides=[chunk.to_slide_outline() for chunk in chunks]
)
except Exception as e:
pass
elif documents:
if documents:
additional_context = "\n\n".join(documents)
if not presentation_outlines:
presentation_outlines_text = ""
async for chunk in generate_ppt_outline(
presentation.content,
presentation.n_slides,
presentation.language,
additional_context,
presentation.tone,
presentation.verbosity,
presentation.instructions,
True,
):
# Give control to the event loop
await asyncio.sleep(0)
presentation_outlines_text = ""
if isinstance(chunk, HTTPException):
yield SSEErrorResponse(detail=chunk.detail).to_string()
return
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": chunk}),
).to_string()
presentation_outlines_text += chunk
try:
presentation_outlines_json = json.loads(presentation_outlines_text)
except Exception as e:
raise HTTPException(
status_code=400,
detail="Failed to generate presentation outlines. Please try again.",
)
presentation_outlines = PresentationOutlineModel(
**presentation_outlines_json
n_slides_to_generate = presentation.n_slides
if presentation.include_table_of_contents:
needed_toc_count = math.ceil((presentation.n_slides - 1) / 10)
n_slides_to_generate -= math.ceil(
(presentation.n_slides - needed_toc_count) / 10
)
async for chunk in generate_ppt_outline(
presentation.content,
n_slides_to_generate,
presentation.language,
additional_context,
presentation.tone,
presentation.verbosity,
presentation.instructions,
True,
):
# Give control to the event loop
await asyncio.sleep(0)
if isinstance(chunk, HTTPException):
yield SSEErrorResponse(detail=chunk.detail).to_string()
return
yield SSEResponse(
event="response",
data=json.dumps({"type": "chunk", "chunk": chunk}),
).to_string()
presentation_outlines_text += chunk
try:
presentation_outlines_json = json.loads(presentation_outlines_text)
except Exception as e:
raise HTTPException(
status_code=400,
detail="Failed to generate presentation outlines. Please try again.",
)
presentation_outlines = PresentationOutlineModel(**presentation_outlines_json)
presentation_outlines.slides = presentation_outlines.slides[
: presentation.n_slides
:n_slides_to_generate
]
presentation.outlines = presentation_outlines.model_dump()
presentation.title = (
presentation_outlines.slides[0]
.content[:50]
.replace("#", "")
.replace("/", "")
.replace("\\", "")
.replace("\n", "")
)
presentation.title = get_presentation_title_from_outlines(presentation_outlines)
sql_session.add(presentation)
await sql_session.commit()

View file

@ -1,8 +1,9 @@
import asyncio
import json
import math
import os
import random
from typing import Annotated, List, Optional
from typing import Annotated, List, Literal, Optional
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import delete
@ -10,11 +11,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from models.generate_presentation_request import GeneratePresentationRequest
from models.presentation_and_path import PresentationPathAndEditPath
from models.presentation_from_template import GetPresentationUsingTemplateRequest
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.pptx_models import PptxPresentationModel
from models.presentation_layout import PresentationLayoutModel
from models.presentation_structure_model import PresentationStructureModel
@ -23,7 +26,6 @@ from models.presentation_with_slides import (
)
from services.documents_loader import DocumentsLoader
from services.score_based_chunker import ScoreBasedChunker
from utils.get_layout_by_name import get_layout_by_name
from services.image_generation_service import ImageGenerationService
from utils.dict_utils import deep_update
@ -43,6 +45,7 @@ from utils.llm_calls.generate_presentation_structure import (
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.process_slides import (
process_slide_add_placeholder_assets,
process_slide_and_fetch_assets,
@ -53,7 +56,32 @@ import uuid
PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"])
@PRESENTATION_ROUTER.get("", response_model=PresentationWithSlides)
@PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides])
async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_session)):
presentations_with_slides = []
query = (
select(PresentationModel, SlideModel)
.join(
SlideModel,
(SlideModel.presentation == PresentationModel.id) & (SlideModel.index == 0),
)
.order_by(PresentationModel.created_at.desc())
)
results = await sql_session.execute(query)
rows = results.all()
presentations_with_slides = [
PresentationWithSlides(
**presentation.model_dump(),
slides=[first_slide],
)
for presentation, first_slide in rows
]
return presentations_with_slides
@PRESENTATION_ROUTER.get("/{id}", response_model=PresentationWithSlides)
async def get_presentation(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
@ -71,7 +99,7 @@ async def get_presentation(
)
@PRESENTATION_ROUTER.delete("", status_code=204)
@PRESENTATION_ROUTER.delete("/{id}", status_code=204)
async def delete_presentation(
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
@ -83,41 +111,27 @@ async def delete_presentation(
await sql_session.commit()
@PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides])
async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_session)):
presentations_with_slides = []
presentations = await sql_session.scalars(select(PresentationModel))
async def inner(presentation: PresentationModel, sql_session: AsyncSession):
first_slide = await sql_session.scalar(
select(SlideModel)
.where(SlideModel.presentation == presentation.id)
.where(SlideModel.index == 0)
)
if not first_slide:
return None
return PresentationWithSlides(
**presentation.model_dump(),
slides=[first_slide],
)
tasks = [inner(p, sql_session) for p in presentations]
results = await asyncio.gather(*tasks)
presentations_with_slides = [r for r in results if r is not None]
return presentations_with_slides
@PRESENTATION_ROUTER.post("/create", response_model=PresentationModel)
async def create_presentation(
content: Annotated[str, Body()],
n_slides: Annotated[int, Body()],
language: Annotated[str, Body()],
file_paths: Annotated[Optional[List[str]], Body()] = None,
tone: Annotated[Optional[str], Body()] = None,
verbosity: Annotated[Optional[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 include_table_of_contents 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()
presentation = PresentationModel(
@ -126,9 +140,12 @@ async def create_presentation(
n_slides=n_slides,
language=language,
file_paths=file_paths,
tone=tone,
verbosity=verbosity,
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)
@ -177,6 +194,42 @@ async def prepare_presentation(
if presentation_structure.slides[index] >= total_slide_layouts:
presentation_structure.slides[index] = random_slide_index
if presentation.include_table_of_contents:
n_toc_slides = presentation.n_slides - total_outlines
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout)
if toc_slide_layout_index != -1:
outline_index = 1 if presentation.include_title_slide else 0
for i in range(n_toc_slides):
outlines_to = outline_index + 10
if total_outlines == outlines_to:
outlines_to -= 1
presentation_structure.slides.insert(
i + 1 if presentation.include_title_slide else i,
toc_slide_layout_index,
)
toc_outline = f"Table of Contents\n\n"
for outline in presentation_outline_model.slides[
outline_index:outlines_to
]:
page_number = (
outline_index - i + n_toc_slides + 1
if presentation.include_title_slide
else outline_index - i + n_toc_slides
)
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
outline_index += 1
outline_index += 1
presentation_outline_model.slides.insert(
i + 1 if presentation.include_title_slide else i,
SlideOutlineModel(
content=toc_outline,
),
)
sql_session.add(presentation)
presentation.outlines = presentation_outline_model.model_dump(mode="json")
presentation.title = title or presentation.title
@ -187,11 +240,11 @@ async def prepare_presentation(
return presentation
@PRESENTATION_ROUTER.get("/stream", response_model=PresentationWithSlides)
@PRESENTATION_ROUTER.get("/stream/{id}", response_model=PresentationWithSlides)
async def stream_presentation(
presentation_id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)
):
presentation = await sql_session.get(PresentationModel, presentation_id)
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
if not presentation.structure:
@ -237,7 +290,7 @@ async def stream_presentation(
return
slide = SlideModel(
presentation=presentation_id,
presentation=id,
layout_group=layout.name,
layout=slide_layout.id,
index=i,
@ -271,7 +324,7 @@ async def stream_presentation(
# Moved this here to make sure new slides are generated before deleting the old ones
await sql_session.execute(
delete(SlideModel).where(SlideModel.presentation == presentation_id)
delete(SlideModel).where(SlideModel.presentation == id)
)
await sql_session.commit()
@ -334,7 +387,7 @@ async def update_presentation(
@PRESENTATION_ROUTER.post("/export/pptx", response_model=str)
async def create_pptx(
async def export_presentation_as_pptx(
pptx_model: Annotated[PptxPresentationModel, Body()],
):
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
@ -351,6 +404,31 @@ async def create_pptx(
return pptx_path
@PRESENTATION_ROUTER.post("/export", response_model=PresentationPathAndEditPath)
async def export_presentation_as_pptx_or_pdf(
id: Annotated[uuid.UUID, Body(description="Presentation ID to export")],
export_as: Annotated[
Literal["pptx", "pdf"], Body(description="Format to export the presentation as")
] = "pptx",
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_and_path = await export_presentation(
id,
presentation.title or str(uuid.uuid4()),
export_as,
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={id}",
)
@PRESENTATION_ROUTER.post("/generate", response_model=PresentationPathAndEditPath)
async def generate_presentation_api(
request: GeneratePresentationRequest,
@ -358,39 +436,47 @@ async def generate_presentation_api(
):
presentation_id = uuid.uuid4()
# 3. Generate Outlines
presentation_outlines = None
additional_context = ""
using_slides_markdown = False
# Process files
if request.files:
documents_loader = DocumentsLoader(file_paths=request.files)
await documents_loader.load_documents()
documents = documents_loader.documents
if documents and len(documents) == 1:
additional_context = documents[0]
chunker = ScoreBasedChunker()
try:
chunks = await chunker.get_n_chunks(documents[0], request.n_slides)
presentation_outlines = PresentationOutlineModel(
slides=[chunk.to_slide_outline() for chunk in chunks]
if request.slides_markdown:
using_slides_markdown = True
request.n_slides = len(request.slides_markdown)
if not using_slides_markdown:
additional_context = ""
if request.files:
documents_loader = DocumentsLoader(file_paths=request.files)
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:
needed_toc_count = math.ceil(
(
(request.n_slides - 1)
if request.include_title_slide
else request.n_slides
)
except Exception as e:
pass
/ 10
)
n_slides_to_generate -= math.ceil(
(request.n_slides - needed_toc_count) / 10
)
elif documents:
additional_context = "\n\n".join(documents)
if not presentation_outlines:
presentation_outlines_text = ""
async for chunk in generate_ppt_outline(
request.content,
request.n_slides,
n_slides_to_generate,
request.language,
additional_context,
request.tone,
request.verbosity,
request.tone.value,
request.verbosity.value,
request.instructions,
request.include_title_slide,
request.web_search,
):
@ -408,18 +494,25 @@ async def generate_presentation_api(
detail="Failed to generate presentation outlines. Please try again.",
)
presentation_outlines = PresentationOutlineModel(**presentation_outlines_json)
total_outlines = n_slides_to_generate
outlines = presentation_outlines.slides[: request.n_slides]
total_outlines = len(outlines)
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)
print("-" * 40)
print(f"Generated {total_outlines} outlines for the presentation")
# 4. Parse Layouts
# Parse Layouts
layout_model = await get_layout_by_name(request.template)
total_slide_layouts = len(layout_model.slides)
# 5. Generate Structure
# Generate Structure
if layout_model.ordered:
presentation_structure = layout_model.to_presentation_structure()
else:
@ -428,6 +521,7 @@ async def generate_presentation_api(
presentation_outlines,
layout_model,
request.instructions,
using_slides_markdown,
)
)
@ -440,7 +534,42 @@ async def generate_presentation_api(
if presentation_structure.slides[index] >= total_slide_layouts:
presentation_structure.slides[index] = random_slide_index
# 6. Create PresentationModel
# Injecting table of contents to the presentation structure and outlines
if request.include_table_of_contents and not using_slides_markdown:
n_toc_slides = request.n_slides - total_outlines
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout_model)
if toc_slide_layout_index != -1:
outline_index = 1 if request.include_title_slide else 0
for i in range(n_toc_slides):
outlines_to = outline_index + 10
if total_outlines == outlines_to:
outlines_to -= 1
presentation_structure.slides.insert(
i + 1 if request.include_title_slide else i,
toc_slide_layout_index,
)
toc_outline = f"Table of Contents\n\n"
for outline in presentation_outlines.slides[outline_index:outlines_to]:
page_number = (
outline_index - i + n_toc_slides + 1
if request.include_title_slide
else outline_index - i + n_toc_slides
)
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
outline_index += 1
outline_index += 1
presentation_outlines.slides.insert(
i + 1 if request.include_title_slide else i,
SlideOutlineModel(
content=toc_outline,
),
)
# Create PresentationModel
presentation = PresentationModel(
id=presentation_id,
content=request.content,
@ -474,10 +603,10 @@ async def generate_presentation_api(
content_tasks = [
get_slide_content_from_type_and_outline(
slide_layouts[i],
outlines[i],
presentation_outlines.slides[i],
request.language,
request.tone,
request.verbosity,
request.tone.value,
request.verbosity.value,
request.instructions,
)
for i in range(start, end)
@ -530,14 +659,57 @@ async def generate_presentation_api(
)
@PRESENTATION_ROUTER.post("/from-template", response_model=PresentationPathAndEditPath)
async def from_template(
data: Annotated[GetPresentationUsingTemplateRequest, Body()],
@PRESENTATION_ROUTER.post("/edit", response_model=PresentationPathAndEditPath)
async def edit_presentation_with_new_content(
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.data))
if new_slide_data:
updated_content = deep_update(each_slide.content, new_slide_data[0].content)
new_slides.append(
each_slide.get_new_slide(presentation.id, updated_content)
)
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
)
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(
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)
)

View file

@ -0,0 +1,11 @@
from enum import Enum
class Tone(str, Enum):
DEFAULT = "default"
CASUAL = "casual"
PROFESSIONAL = "professional"
FUNNY = "funny"
EDUCATIONAL = "educational"
SALES_PITCH = "sales_pitch"

View file

@ -0,0 +1,8 @@
from enum import Enum
class Verbosity(str, Enum):
CONCISE = "concise"
STANDARD = "standard"
TEXT_HEAVY = "text-heavy"

View file

@ -1,17 +1,21 @@
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
from enums.tone import Tone
from enums.verbosity import Verbosity
class GeneratePresentationRequest(BaseModel):
content: str = Field(..., description="The content for generating the presentation")
slides_markdown: Optional[List[str]] = Field(
default=None, description="The markdown for the slides"
)
instructions: Optional[str] = Field(
default=None, description="The instruction for generating the presentation"
)
tone: Optional[str] = Field(
default=None, description="The tone for the presentation"
)
verbosity: Optional[str] = Field(
default=None, description="The verbosity for the presentation"
tone: Tone = Field(default=Tone.DEFAULT, description="The tone to use for the text")
verbosity: Verbosity = Field(
default=Verbosity.STANDARD, description="How verbose the presentation should be"
)
web_search: bool = Field(default=False, description="Whether to enable web search")
n_slides: int = Field(default=8, description="Number of slides to generate")
@ -21,6 +25,12 @@ class GeneratePresentationRequest(BaseModel):
template: str = Field(
default="general", description="Template to use for the presentation"
)
include_table_of_contents: bool = Field(
default=False, description="Whether to include a table of contents"
)
include_title_slide: bool = Field(
default=True, description="Whether to include a title slide"
)
files: Optional[List[str]] = Field(
default=None, description="Files to use for the presentation"
)

View file

@ -8,7 +8,7 @@ class SlideContentUpdate(BaseModel):
content: dict
class GetPresentationUsingTemplateRequest(BaseModel):
class EditPresentationRequest(BaseModel):
presentation_id: uuid.UUID
data: List[SlideContentUpdate]
export_as: Literal["pptx", "pdf"] = "pptx"

View file

@ -9,7 +9,6 @@ from models.sql.slide import SlideModel
class PresentationWithSlides(BaseModel):
id: uuid.UUID
user: uuid.UUID
content: str
n_slides: int
language: str

View file

@ -2,7 +2,7 @@ from datetime import datetime
from typing import List, Optional
import uuid
from sqlalchemy import JSON, Column, DateTime, String
from sqlmodel import Field, SQLModel
from sqlmodel import Boolean, Field, SQLModel
from models.presentation_layout import PresentationLayoutModel
from models.presentation_outline_model import PresentationOutlineModel
@ -38,6 +38,9 @@ class PresentationModel(SQLModel, table=True):
instructions: Optional[str] = Field(sa_column=Column(String), default=None)
tone: Optional[str] = Field(sa_column=Column(String), default=None)
verbosity: Optional[str] = Field(sa_column=Column(String), default=None)
include_table_of_contents: bool = Field(sa_column=Column(Boolean), default=False)
include_title_slide: bool = Field(sa_column=Column(Boolean), default=True)
web_search: bool = Field(sa_column=Column(Boolean), default=False)
def get_new_presentation(self):
return PresentationModel(
@ -51,6 +54,10 @@ class PresentationModel(SQLModel, table=True):
layout=self.layout,
structure=self.structure,
instructions=self.instructions,
tone=self.tone,
verbosity=self.verbosity,
include_table_of_contents=self.include_table_of_contents,
include_title_slide=self.include_title_slide,
)
def get_presentation_outline(self):

View file

@ -1,53 +0,0 @@
from fastapi import UploadFile
import os
import uuid
from models.sql.image_asset import ImageAsset
from utils.asset_directory_utils import get_uploads_directory
class ImageUploadService:
"""Handles saving uploaded images to disk and returning ImageAsset models."""
def __init__(self, output_directory: str | None = None):
# Prefer provided directory, otherwise resolve from app data directory
self.uploads_directory = output_directory or get_uploads_directory()
os.makedirs(self.uploads_directory, exist_ok=True)
async def upload_image(self, file: UploadFile) -> ImageAsset:
"""Save the uploaded file to disk and return an ImageAsset (not committed).
The caller is responsible for adding the returned ImageAsset to the
database session and committing.
"""
file_extension = os.path.splitext(file.filename)[1] if file.filename else ""
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = os.path.join(self.uploads_directory, unique_filename)
content = await file.read()
with open(file_path, "wb") as buffer:
buffer.write(content)
image_asset = ImageAsset(
path=file_path,
is_uploaded=True,
extras={
"original_filename": file.filename,
"content_type": file.content_type,
"file_size": len(content),
},
)
return image_asset
async def delete_image(self, file_path: str) -> bool:
"""Delete an image file from disk by its absolute path."""
if not file_path:
return False
if not os.path.isabs(file_path):
# Ensure we only operate on absolute paths that we generated
file_path = os.path.join(self.uploads_directory, file_path)
if not os.path.exists(file_path):
return False
os.remove(file_path)
return True

View file

@ -0,0 +1,44 @@
import os
from typing import BinaryIO
import uuid
from fastapi import UploadFile
def replace_file_name(filename: str, new_stem: str) -> str:
_, ext = os.path.splitext(filename)
return f"{new_stem}{ext}"
def get_file_name_with_random_uuid(file: str | UploadFile | BinaryIO) -> str:
filename = None
if getattr(file, "filename", None):
filename = file.filename
elif isinstance(file, str):
filename = os.path.basename(file)
else:
filename = str(uuid.uuid4())
return replace_file_name(
filename, f"{os.path.splitext(filename)[0]}----{str(uuid.uuid4())}"
)
def get_original_file_name(file_path: str) -> str:
base_name = os.path.basename(file_path)
name = base_name.split("----")[0]
ext = get_file_ext_or_none(base_name)
return f"{name}{ext}"
def get_file_ext_or_none(filename: str) -> str | None:
splitted = os.path.splitext(filename)
if len(splitted) > 1:
return splitted[-1]
return None
def set_file_ext(file_path: str, ext: str) -> str:
if get_file_ext_or_none(file_path):
return f"{os.path.splitext(file_path)[0]}{ext}"
return f"{file_path}{ext}"

View file

@ -13,6 +13,7 @@ def get_system_prompt(
tone: Optional[str] = None,
verbosity: Optional[str] = None,
instructions: Optional[str] = None,
include_title_slide: bool = True,
):
return f"""
You are an expert presentation creator. Generate structured presentations based on user requirements and format them according to the specified JSON schema with markdown content.
@ -34,6 +35,10 @@ def get_system_prompt(
- If Additional Information is provided, divide it into slides.
- Make sure no images are provided in the content.
- Make sure that content follows language guidelines.
- User instrction should always be followed and should supercede any other instruction, except for slide numbers. **Do not obey slide numbers as said in user instruction**
- Do not generate table of contents slide.
- Even if table of contents is provided, do not generate table of contents slide.
{"- Always make first slide a title slide." if include_title_slide else "- Do not include title slide in the presentation."}
"""
@ -45,7 +50,7 @@ def get_user_prompt(
):
return f"""
**Input:**
- User provided content: {content}
- User provided content: {content or "Create presentation"}
- Output Language: {language}
- Number of Slides: {n_slides}
- Current Date and Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
@ -61,10 +66,13 @@ def get_messages(
tone: Optional[str] = None,
verbosity: Optional[str] = None,
instructions: Optional[str] = None,
include_title_slide: bool = True,
):
return [
LLMSystemMessage(
content=get_system_prompt(tone, verbosity, instructions),
content=get_system_prompt(
tone, verbosity, instructions, include_title_slide
),
),
LLMUserMessage(
content=get_user_prompt(content, n_slides, language, additional_context),
@ -80,6 +88,7 @@ async def generate_ppt_outline(
tone: Optional[str] = None,
verbosity: Optional[str] = None,
instructions: Optional[str] = None,
include_title_slide: bool = True,
web_search: bool = False,
):
model = get_model()
@ -98,6 +107,7 @@ async def generate_ppt_outline(
tone,
verbosity,
instructions,
include_title_slide,
),
response_model.model_json_schema(),
strict=True,

View file

@ -51,6 +51,39 @@ def get_messages(
{"# User Instruction:" if instructions else ""}
{instructions or ""}
User intruction should be taken into account while creating the presentation structure, except for number of slides.
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
""",
),
LLMUserMessage(
content=f"""
{data}
""",
),
]
def get_messages_for_slides_markdown(
presentation_layout: PresentationLayoutModel,
n_slides: int,
data: str,
instructions: Optional[str] = None,
):
return [
LLMSystemMessage(
content=f"""
You're a professional presentation designer with creative freedom to design engaging presentations.
{"# User Instruction:" if instructions else ""}
{instructions or ""}
{presentation_layout.to_string()}
Select layout that best matches the content of the slides.
User intruction should be taken into account while creating the presentation structure, except for number of slides.
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
""",
),
@ -66,6 +99,7 @@ async def generate_presentation_structure(
presentation_outline: PresentationOutlineModel,
presentation_layout: PresentationLayoutModel,
instructions: Optional[str] = None,
using_slides_markdown: bool = False,
) -> PresentationStructureModel:
client = LLMClient()
@ -77,11 +111,20 @@ async def generate_presentation_structure(
try:
response = await client.generate_structured(
model=model,
messages=get_messages(
presentation_layout,
len(presentation_outline.slides),
presentation_outline.to_string(),
instructions,
messages=(
get_messages_for_slides_markdown(
presentation_layout,
len(presentation_outline.slides),
presentation_outline.to_string(),
instructions,
)
if using_slides_markdown
else get_messages(
presentation_layout,
len(presentation_outline.slides),
presentation_outline.to_string(),
instructions,
)
),
response_format=response_model.model_json_schema(),
strict=True,

View file

@ -40,6 +40,18 @@ def get_system_prompt(
- Strictly follow the max and min character limit for every property in the slide.
- Never ever go over the max character limit. Limit your narration to make sure you never go over the max character limit.
- Number of items should not be more than max number of items specified in slide schema. If you have to put multiple points then merge them to obey max numebr of items.
- Generate content as per the given tone.
- Be very careful with number of words to generate for given field. As generating more than max characters will overflow in the design. So, analyze early and never generate more characters than allowed.
- Do not add emoji in the content.
- Metrics should be in abbreviated form with least possible characters. Do not add long sequence of words for metrics.
- For verbosity:
- If verbosity is 'concise', then generate description as 1/3 or lower of the max character limit. Don't worry if you miss content or context.
- If verbosity is 'standard', then generate description as 2/3 of the max character limit.
- If verbosity is 'text-heavy', then generate description as 3/4 or higher of the max character limit. Make sure it does not exceed the max character limit.
User instructions, tone and verbosity should always be followed and should supercede any other instruction, except for max and min character limit, slide schema and number of items.
- Provide output in json format and **don't include <parameters> tags**.
# Image and Icon Output Format
image: {{

View file

@ -0,0 +1,82 @@
from models.presentation_layout import PresentationLayoutModel
from models.presentation_outline_model import PresentationOutlineModel
import re
from typing import List
from models.presentation_structure_model import PresentationStructureModel
def get_presentation_title_from_outlines(
presentation_outlines: PresentationOutlineModel,
) -> str:
if not presentation_outlines.slides:
return "Untitled Presentation"
first_content = presentation_outlines.slides[0].content or ""
if re.match(r"^\s*#{1,6}\s*Page\s+\d+\b", first_content):
first_content = re.sub(
r"^\s*#{1,6}\s*Page\s+\d+\b[\s,:\-]*",
"",
first_content,
count=1,
)
return (
first_content[:100]
.replace("#", "")
.replace("/", "")
.replace("\\", "")
.replace("\n", " ")
)
def find_slide_layout_index_by_regex(
layout: PresentationLayoutModel, patterns: List[str]
) -> int:
def _find_index(pattern: str) -> int:
regex = re.compile(pattern, re.IGNORECASE)
for index, slide_layout in enumerate(layout.slides):
candidates = [
slide_layout.id or "",
(slide_layout.name or ""),
(slide_layout.description or ""),
(slide_layout.json_schema.get("title") if slide_layout.json_schema else ""),
]
for text in candidates:
if text and regex.search(text):
return index
return -1
for pattern in patterns:
match_index = _find_index(pattern)
if match_index != -1:
return match_index
return -1
def select_toc_or_list_slide_layout_index(
layout: PresentationLayoutModel,
) -> int:
toc_patterns = [
r"\btable\s*of\s*contents\b",
r"\btable[- ]?of[- ]?contents\b",
r"\bagenda\b",
r"\bcontents\b",
r"\boutline\b",
r"\bindex\b",
r"\btoc\b",
]
list_patterns = [
r"\b(bullet(ed)?\s*list|bullets?)\b",
r"\b(numbered\s*list|ordered\s*list|unordered\s*list)\b",
r"\blist\b",
]
toc_index = find_slide_layout_index_by_regex(layout, toc_patterns)
if toc_index != -1:
return toc_index
return find_slide_layout_index_by_regex(layout, list_patterns)

View file

@ -29,7 +29,7 @@ export const useOutlineStreaming = (presentationId: string | null) => {
setIsLoading(true)
try {
eventSource = new EventSource(
`/api/v1/ppt/outlines/stream?presentation_id=${presentationId}`
`/api/v1/ppt/outlines/stream/${presentationId}`
);
eventSource.addEventListener("response", (event) => {
@ -42,7 +42,7 @@ export const useOutlineStreaming = (presentationId: string | null) => {
try {
const repairedJson = jsonrepair(accumulatedChunks);
const partialData = JSON.parse(repairedJson);
if (partialData.slides) {
const nextSlides: { content: string }[] = partialData.slides || [];
// Determine which slide index changed to minimize live parsing
@ -70,7 +70,7 @@ export const useOutlineStreaming = (presentationId: string | null) => {
highestIndexRef.current = nextActive;
setHighestActiveIndex(nextActive);
}
} catch {}
} catch { }
prevSlidesRef.current = nextSlides;
dispatch(setOutlines(nextSlides));
@ -82,18 +82,18 @@ export const useOutlineStreaming = (presentationId: string | null) => {
break;
case "complete":
try {
const outlinesData: { content: string }[] = data.presentation.outlines.slides;
dispatch(setOutlines(outlinesData));
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)
setHighestActiveIndex(-1)
prevSlidesRef.current = outlinesData;
activeIndexRef.current = -1;
highestIndexRef.current = -1;
eventSource.close();
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)
setHighestActiveIndex(-1)
prevSlidesRef.current = outlinesData;
activeIndexRef.current = -1;
highestIndexRef.current = -1;
eventSource.close();
} catch (error) {
console.error("Error parsing accumulated chunks:", error);
toast.error("Failed to parse presentation data");
@ -103,7 +103,7 @@ export const useOutlineStreaming = (presentationId: string | null) => {
break;
case "closing":
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)
@ -113,7 +113,7 @@ export const useOutlineStreaming = (presentationId: string | null) => {
eventSource.close();
break;
case "error":
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)
@ -131,7 +131,7 @@ export const useOutlineStreaming = (presentationId: string | null) => {
});
eventSource.onerror = () => {
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)
@ -142,7 +142,7 @@ export const useOutlineStreaming = (presentationId: string | null) => {
toast.error("Failed to connect to the server. Please try again.");
};
} catch (error) {
setIsStreaming(false)
setIsLoading(false)
setActiveSlideIndex(null)

View file

@ -30,7 +30,7 @@ export const usePresentationStreaming = (
trackEvent(MixpanelEvent.Presentation_Stream_API_Call);
eventSource = new EventSource(
`/api/v1/ppt/presentation/stream?presentation_id=${presentationId}`
`/api/v1/ppt/presentation/stream/${presentationId}`
);
eventSource.addEventListener("response", (event) => {

View file

@ -49,7 +49,7 @@ export class DashboardApi {
static async getPresentation(id: string) {
try {
const response = await fetch(
`/api/v1/ppt/presentation?id=${id}`,
`/api/v1/ppt/presentation/${id}`,
{
method: "GET",
}
@ -65,7 +65,7 @@ export class DashboardApi {
static async deletePresentation(presentation_id: string) {
try {
const response = await fetch(
`/api/v1/ppt/presentation?id=${presentation_id}`,
`/api/v1/ppt/presentation/${presentation_id}`,
{
method: "DELETE",
headers: getHeader(),

View file

@ -9,7 +9,7 @@ export class ImagesApi {
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`/api/v1/ppt/images/upload-image`, {
const response = await fetch(`/api/v1/ppt/images/upload`, {
method: "POST",
headers: getHeaderForFormData(),
body: formData,
@ -33,7 +33,7 @@ export class ImagesApi {
static async deleteImage(image_id: string): Promise<{success: boolean, message?: string}> {
try {
const response = await fetch(`/api/v1/ppt/images/uploaded-image/${image_id}`, {
const response = await fetch(`/api/v1/ppt/images/${image_id}`, {
method: "DELETE"
});
return await ApiResponseHandler.handleResponse(response, "Failed to delete image") as {success: boolean, message?: string};