From 3c5ba63309a9caf7a1644f0e7e417fd29bd7e924 Mon Sep 17 00:00:00 2001 From: sauravniraula Date: Fri, 12 Sep 2025 01:28:59 +0545 Subject: [PATCH] feat(fastapi): uses better json loader that parses dirty json --- Dockerfile | 2 +- Dockerfile.dev | 2 +- .../fastapi/api/v1/ppt/endpoints/outlines.py | 3 +- .../api/v1/ppt/endpoints/presentation.py | 3 +- .../api/v1/ppt/endpoints/slide_to_html.py | 514 ++++++++++-------- servers/fastapi/pyproject.toml | 1 + servers/fastapi/services/documents_loader.py | 8 +- servers/fastapi/services/llm_client.py | 5 +- .../generate_presentation_outlines.py | 2 + .../fastapi/utils/llm_client_error_handler.py | 2 + servers/fastapi/uv.lock | 11 + 11 files changed, 321 insertions(+), 232 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3cfa3888..ca01ce27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ RUN curl -fsSL https://ollama.com/install.sh | sh # Install dependencies for FastAPI RUN pip install aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \ pathvalidate pdfplumber chromadb sqlmodel \ - anthropic google-genai openai fastmcp + anthropic google-genai openai fastmcp dirtyjson RUN pip install docling --extra-index-url https://download.pytorch.org/whl/cpu # Install dependencies for Next.js diff --git a/Dockerfile.dev b/Dockerfile.dev index 7e71c71b..45a488fa 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -29,7 +29,7 @@ RUN curl -fsSL http://ollama.com/install.sh | sh # Install dependencies for FastAPI RUN pip install aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \ pathvalidate pdfplumber chromadb sqlmodel \ - anthropic google-genai openai fastmcp + anthropic google-genai openai fastmcp dirtyjson RUN pip install docling --extra-index-url https://download.pytorch.org/whl/cpu # Install dependencies for Next.js diff --git a/servers/fastapi/api/v1/ppt/endpoints/outlines.py b/servers/fastapi/api/v1/ppt/endpoints/outlines.py index d5ee6a2a..cc097995 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/outlines.py +++ b/servers/fastapi/api/v1/ppt/endpoints/outlines.py @@ -2,6 +2,7 @@ import asyncio import json import math import uuid +import dirtyjson from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -82,7 +83,7 @@ async def stream_outlines( presentation_outlines_text += chunk try: - presentation_outlines_json = json.loads(presentation_outlines_text) + presentation_outlines_json = dict(dirtyjson.loads(presentation_outlines_text)) except Exception as e: raise HTTPException( status_code=400, diff --git a/servers/fastapi/api/v1/ppt/endpoints/presentation.py b/servers/fastapi/api/v1/ppt/endpoints/presentation.py index 9b7a1ae7..927f8630 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/presentation.py +++ b/servers/fastapi/api/v1/ppt/endpoints/presentation.py @@ -4,6 +4,7 @@ import math import os import random from typing import Annotated, List, Literal, Optional +import dirtyjson from fastapi import APIRouter, Body, Depends, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy import delete @@ -486,7 +487,7 @@ async def generate_presentation_api( presentation_outlines_text += chunk try: - presentation_outlines_json = json.loads(presentation_outlines_text) + presentation_outlines_json = dict(dirtyjson.loads(presentation_outlines_text)) except Exception as e: print(e) raise HTTPException( diff --git a/servers/fastapi/api/v1/ppt/endpoints/slide_to_html.py b/servers/fastapi/api/v1/ppt/endpoints/slide_to_html.py index 21bd8ea4..5025ce4a 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/slide_to_html.py +++ b/servers/fastapi/api/v1/ppt/endpoints/slide_to_html.py @@ -12,7 +12,11 @@ from sqlalchemy import select, delete, func from utils.asset_directory_utils import get_images_directory from services.database import get_async_session from models.sql.presentation_layout_code import PresentationLayoutCodeModel -from .prompts import GENERATE_HTML_SYSTEM_PROMPT, HTML_TO_REACT_SYSTEM_PROMPT, HTML_EDIT_SYSTEM_PROMPT +from .prompts import ( + GENERATE_HTML_SYSTEM_PROMPT, + HTML_TO_REACT_SYSTEM_PROMPT, + HTML_EDIT_SYSTEM_PROMPT, +) from models.sql.template import TemplateModel @@ -20,15 +24,18 @@ from models.sql.template import TemplateModel SLIDE_TO_HTML_ROUTER = APIRouter(prefix="/slide-to-html", tags=["slide-to-html"]) HTML_TO_REACT_ROUTER = APIRouter(prefix="/html-to-react", tags=["html-to-react"]) HTML_EDIT_ROUTER = APIRouter(prefix="/html-edit", tags=["html-edit"]) -LAYOUT_MANAGEMENT_ROUTER = APIRouter(prefix="/template-management", tags=["template-management"]) +LAYOUT_MANAGEMENT_ROUTER = APIRouter( + prefix="/template-management", tags=["template-management"] +) # Request/Response models for slide-to-html endpoint class SlideToHtmlRequest(BaseModel): image: str # Partial path to image file (e.g., "/app_data/images/uuid/slide_1.png") - xml: str # OXML content as text + xml: str # OXML content as text fonts: Optional[List[str]] = None # Optional normalized root fonts for this slide + class SlideToHtmlResponse(BaseModel): success: bool html: str @@ -43,7 +50,7 @@ class HtmlEditResponse(BaseModel): # Request/Response models for html-to-react endpoint class HtmlToReactRequest(BaseModel): - html: str # HTML content to convert to React component + html: str # HTML content to convert to React component image: Optional[str] = None # Optional image path to provide visual context @@ -56,9 +63,9 @@ class HtmlToReactResponse(BaseModel): # Request/Response models for layout management endpoints class LayoutData(BaseModel): presentation: UUID # UUID of the presentation - layout_id: str # Unique identifier for the layout - layout_name: str # Display name of the layout - layout_code: str # TSX/React component code for the layout + layout_id: str # Unique identifier for the layout + layout_name: str # Display name of the layout + layout_code: str # TSX/React component code for the layout fonts: Optional[List[str]] = None # Optional list of font links @@ -120,30 +127,42 @@ class TemplateInfo(BaseModel): created_at: Optional[datetime] = None -async def generate_html_from_slide(base64_image: str, media_type: str, xml_content: str, api_key: str, fonts: Optional[List[str]] = None) -> str: +async def generate_html_from_slide( + base64_image: str, + media_type: str, + xml_content: str, + api_key: str, + fonts: Optional[List[str]] = None, +) -> str: """ Generate HTML content from slide image and XML using OpenAI GPT-5 Responses API. - + Args: base64_image: Base64 encoded image data media_type: MIME type of the image (e.g., 'image/png') xml_content: OXML content as text api_key: OpenAI API key fonts: Optional list of normalized root font families to prefer in output - + Returns: Generated HTML content as string - + Raises: HTTPException: If API call fails or no content is generated """ - print(f"Generating HTML from slide image and XML using OpenAI GPT-5 Responses API...") + print( + f"Generating HTML from slide image and XML using OpenAI GPT-5 Responses API..." + ) try: client = OpenAI(api_key=api_key) # Compose input for Responses API. Include system prompt, image (separate), OXML and optional fonts text. data_url = f"data:{media_type};base64,{base64_image}" - fonts_text = f"\nFONTS (Normalized root families used in this slide, use where it is required): {', '.join(fonts)}" if fonts else "" + fonts_text = ( + f"\nFONTS (Normalized root families used in this slide, use where it is required): {', '.join(fonts)}" + if fonts + else "" + ) user_text = f"OXML: \n\n{fonts_text}" input_payload = [ {"role": "system", "content": GENERATE_HTML_SYSTEM_PROMPT}, @@ -165,23 +184,25 @@ async def generate_html_from_slide(base64_image: str, media_type: str, xml_conte ) # Extract the response text - html_content = getattr(response, "output_text", None) or getattr(response, "text", None) or "" - + html_content = ( + getattr(response, "output_text", None) + or getattr(response, "text", None) + or "" + ) + print(f"Received HTML content length: {len(html_content)}") - + if not html_content: raise HTTPException( - status_code=500, - detail="No HTML content generated by OpenAI GPT-5" + status_code=500, detail="No HTML content generated by OpenAI GPT-5" ) - + return html_content - + except APIError as e: print(f"OpenAI API Error: {e}") raise HTTPException( - status_code=500, - detail=f"OpenAI API error during HTML generation: {str(e)}" + status_code=500, detail=f"OpenAI API error during HTML generation: {str(e)}" ) except Exception as e: # Handle various API errors @@ -191,31 +212,36 @@ async def generate_html_from_slide(base64_image: str, media_type: str, xml_conte if "timeout" in error_msg.lower(): raise HTTPException( status_code=408, - detail=f"OpenAI API timeout during HTML generation: {error_msg}" + detail=f"OpenAI API timeout during HTML generation: {error_msg}", ) elif "connection" in error_msg.lower(): raise HTTPException( status_code=503, - detail=f"OpenAI API connection error during HTML generation: {error_msg}" + detail=f"OpenAI API connection error during HTML generation: {error_msg}", ) else: raise HTTPException( status_code=500, - detail=f"OpenAI API error during HTML generation: {error_msg}" + detail=f"OpenAI API error during HTML generation: {error_msg}", ) -async def generate_react_component_from_html(html_content: str, api_key: str, image_base64: Optional[str] = None, media_type: Optional[str] = None) -> str: +async def generate_react_component_from_html( + html_content: str, + api_key: str, + image_base64: Optional[str] = None, + media_type: Optional[str] = None, +) -> str: """ Convert HTML content to TSX React component using OpenAI GPT-5 Responses API. - + Args: html_content: Generated HTML content api_key: OpenAI API key - + Returns: Generated TSX React component code as string - + Raises: HTTPException: If API call fails or no content is generated """ @@ -242,35 +268,45 @@ async def generate_react_component_from_html(html_content: str, api_key: str, im text={"verbosity": "low"}, ) - react_content = getattr(response, "output_text", None) or getattr(response, "text", None) or "" - + react_content = ( + getattr(response, "output_text", None) + or getattr(response, "text", None) + or "" + ) + print(f"Received React content length: {len(react_content)}") - + if not react_content: raise HTTPException( - status_code=500, - detail="No React component generated by OpenAI GPT-5" + status_code=500, detail="No React component generated by OpenAI GPT-5" ) - - react_content = react_content.replace("```tsx", "").replace("```", "").replace("typescript", "").replace("javascript", "") - + react_content = ( + react_content.replace("```tsx", "") + .replace("```", "") + .replace("typescript", "") + .replace("javascript", "") + ) + # Filter out lines that start with import or export filtered_lines = [] - for line in react_content.split('\n'): + for line in react_content.split("\n"): stripped_line = line.strip() - if not (stripped_line.startswith('import ') or stripped_line.startswith('export ')): + if not ( + stripped_line.startswith("import ") + or stripped_line.startswith("export ") + ): filtered_lines.append(line) - - filtered_react_content = '\n'.join(filtered_lines) + + filtered_react_content = "\n".join(filtered_lines) print(f"Filtered React content length: {len(filtered_react_content)}") - + return filtered_react_content except APIError as e: print(f"OpenAI API Error: {e}") raise HTTPException( status_code=500, - detail=f"OpenAI API error during React generation: {str(e)}" + detail=f"OpenAI API error during React generation: {str(e)}", ) except Exception as e: # Handle various API errors @@ -280,21 +316,28 @@ async def generate_react_component_from_html(html_content: str, api_key: str, im if "timeout" in error_msg.lower(): raise HTTPException( status_code=408, - detail=f"OpenAI API timeout during React generation: {error_msg}" + detail=f"OpenAI API timeout during React generation: {error_msg}", ) elif "connection" in error_msg.lower(): raise HTTPException( status_code=503, - detail=f"OpenAI API connection error during React generation: {error_msg}" + detail=f"OpenAI API connection error during React generation: {error_msg}", ) else: raise HTTPException( status_code=500, - detail=f"OpenAI API error during React generation: {error_msg}" + detail=f"OpenAI API error during React generation: {error_msg}", ) -async def edit_html_with_images(current_ui_base64: str, sketch_base64: Optional[str], media_type: str, html_content: str, prompt: str, api_key: str) -> str: +async def edit_html_with_images( + current_ui_base64: str, + sketch_base64: Optional[str], + media_type: str, + html_content: str, + prompt: str, + api_key: str, +) -> str: """ Edit HTML content based on one or two images and a text prompt using OpenAI GPT-5 Responses API. @@ -305,10 +348,10 @@ async def edit_html_with_images(current_ui_base64: str, sketch_base64: Optional[ html_content: Current HTML content to edit prompt: Text prompt describing the changes api_key: OpenAI API key - + Returns: Edited HTML content as string - + Raises: HTTPException: If API call fails or no content is generated """ @@ -318,15 +361,22 @@ async def edit_html_with_images(current_ui_base64: str, sketch_base64: Optional[ print("Making Responses API request for HTML editing...") current_data_url = f"data:{media_type};base64,{current_ui_base64}" - sketch_data_url = f"data:{media_type};base64,{sketch_base64}" if sketch_base64 else None + sketch_data_url = ( + f"data:{media_type};base64,{sketch_base64}" if sketch_base64 else None + ) content_parts = [ {"type": "input_image", "image_url": current_data_url}, - {"type": "input_text", "text": f"CURRENT HTML TO EDIT:\n{html_content}\n\nTEXT PROMPT FOR CHANGES:\n{prompt}"}, + { + "type": "input_text", + "text": f"CURRENT HTML TO EDIT:\n{html_content}\n\nTEXT PROMPT FOR CHANGES:\n{prompt}", + }, ] if sketch_data_url: # Insert sketch image after current UI image for context - content_parts.insert(1, {"type": "input_image", "image_url": sketch_data_url}) + content_parts.insert( + 1, {"type": "input_image", "image_url": sketch_data_url} + ) input_payload = [ {"role": "system", "content": HTML_EDIT_SYSTEM_PROMPT}, @@ -340,23 +390,26 @@ async def edit_html_with_images(current_ui_base64: str, sketch_base64: Optional[ text={"verbosity": "low"}, ) - edited_html = getattr(response, "output_text", None) or getattr(response, "text", None) or "" - + edited_html = ( + getattr(response, "output_text", None) + or getattr(response, "text", None) + or "" + ) + print(f"Received edited HTML content length: {len(edited_html)}") - + if not edited_html: raise HTTPException( status_code=500, - detail="No edited HTML content generated by OpenAI GPT-5" + detail="No edited HTML content generated by OpenAI GPT-5", ) - + return edited_html - + except APIError as e: print(f"OpenAI API Error: {e}") raise HTTPException( - status_code=500, - detail=f"OpenAI API error during HTML editing: {str(e)}" + status_code=500, detail=f"OpenAI API error during HTML editing: {str(e)}" ) except Exception as e: # Handle various API errors @@ -366,17 +419,17 @@ async def edit_html_with_images(current_ui_base64: str, sketch_base64: Optional[ if "timeout" in error_msg.lower(): raise HTTPException( status_code=408, - detail=f"OpenAI API timeout during HTML editing: {error_msg}" + detail=f"OpenAI API timeout during HTML editing: {error_msg}", ) elif "connection" in error_msg.lower(): raise HTTPException( status_code=503, - detail=f"OpenAI API connection error during HTML editing: {error_msg}" + detail=f"OpenAI API connection error during HTML editing: {error_msg}", ) else: raise HTTPException( status_code=500, - detail=f"OpenAI API error during HTML editing: {error_msg}" + detail=f"OpenAI API error during HTML editing: {error_msg}", ) @@ -385,10 +438,10 @@ async def edit_html_with_images(current_ui_base64: str, sketch_base64: Optional[ async def convert_slide_to_html(request: SlideToHtmlRequest): """ Convert a slide image and its OXML data to HTML using Anthropic Claude API. - + Args: request: JSON request containing image path and XML content - + Returns: SlideToHtmlResponse with generated HTML """ @@ -397,21 +450,20 @@ async def convert_slide_to_html(request: SlideToHtmlRequest): api_key = os.getenv("OPENAI_API_KEY") if not api_key: raise HTTPException( - status_code=500, - detail="OPENAI_API_KEY environment variable not set" + status_code=500, detail="OPENAI_API_KEY environment variable not set" ) - + # Resolve image path to actual file system path image_path = request.image - + # Handle different path formats if image_path.startswith("/app_data/images/"): # Remove the /app_data/images/ prefix and join with actual images directory - relative_path = image_path[len("/app_data/images/"):] + relative_path = image_path[len("/app_data/images/") :] actual_image_path = os.path.join(get_images_directory(), relative_path) elif image_path.startswith("/static/"): # Handle static files - relative_path = image_path[len("/static/"):] + relative_path = image_path[len("/static/") :] actual_image_path = os.path.join("static", relative_path) else: # Assume it's already a full path or relative to images directory @@ -419,30 +471,29 @@ async def convert_slide_to_html(request: SlideToHtmlRequest): actual_image_path = image_path else: actual_image_path = os.path.join(get_images_directory(), image_path) - + # Check if image file exists if not os.path.exists(actual_image_path): raise HTTPException( - status_code=404, - detail=f"Image file not found: {image_path}" + status_code=404, detail=f"Image file not found: {image_path}" ) - + # Read and encode image to base64 with open(actual_image_path, "rb") as image_file: image_content = image_file.read() - base64_image = base64.b64encode(image_content).decode('utf-8') - + base64_image = base64.b64encode(image_content).decode("utf-8") + # Determine media type from file extension file_extension = os.path.splitext(actual_image_path)[1].lower() media_type_map = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp' + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", } - media_type = media_type_map.get(file_extension, 'image/png') - + media_type = media_type_map.get(file_extension, "image/png") + # Generate HTML using the extracted function html_content = await generate_html_from_slide( base64_image=base64_image, @@ -450,15 +501,12 @@ async def convert_slide_to_html(request: SlideToHtmlRequest): xml_content=request.xml, api_key=api_key, fonts=request.fonts, - ) - - html_content = html_content.replace("```html", "").replace("```", "") - - return SlideToHtmlResponse( - success=True, - html=html_content ) - + + html_content = html_content.replace("```html", "").replace("```", "") + + return SlideToHtmlResponse(success=True, html=html_content) + except HTTPException: # Re-raise HTTP exceptions as-is raise @@ -466,8 +514,7 @@ async def convert_slide_to_html(request: SlideToHtmlRequest): # Log the full error for debugging print(f"Unexpected error during slide to HTML processing: {str(e)}") raise HTTPException( - status_code=500, - detail=f"Error processing slide to HTML: {str(e)}" + status_code=500, detail=f"Error processing slide to HTML: {str(e)}" ) @@ -476,10 +523,10 @@ async def convert_slide_to_html(request: SlideToHtmlRequest): async def convert_html_to_react(request: HtmlToReactRequest): """ Convert HTML content to TSX React component using Anthropic Claude API. - + Args: request: JSON request containing HTML content - + Returns: HtmlToReactResponse with generated React component """ @@ -488,52 +535,58 @@ async def convert_html_to_react(request: HtmlToReactRequest): api_key = os.getenv("OPENAI_API_KEY") if not api_key: raise HTTPException( - status_code=500, - detail="OPENAI_API_KEY environment variable not set" + status_code=500, detail="OPENAI_API_KEY environment variable not set" ) - + # Validate HTML content if not request.html or not request.html.strip(): - raise HTTPException( - status_code=400, - detail="HTML content cannot be empty" - ) - + raise HTTPException(status_code=400, detail="HTML content cannot be empty") + # Optionally resolve image and encode to base64 image_b64 = None media_type = None if request.image: image_path = request.image if image_path.startswith("/app_data/images/"): - relative_path = image_path[len("/app_data/images/"):] + relative_path = image_path[len("/app_data/images/") :] actual_image_path = os.path.join(get_images_directory(), relative_path) elif image_path.startswith("/static/"): - relative_path = image_path[len("/static/"):] + relative_path = image_path[len("/static/") :] actual_image_path = os.path.join("static", relative_path) else: - actual_image_path = image_path if os.path.isabs(image_path) else os.path.join(get_images_directory(), image_path) + actual_image_path = ( + image_path + if os.path.isabs(image_path) + else os.path.join(get_images_directory(), image_path) + ) if os.path.exists(actual_image_path): with open(actual_image_path, "rb") as f: image_b64 = base64.b64encode(f.read()).decode("utf-8") ext = os.path.splitext(actual_image_path)[1].lower() - media_type = {'.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.gif':'image/gif','.webp':'image/webp'}.get(ext, 'image/png') - + media_type = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + }.get(ext, "image/png") + # Convert HTML to React component react_component = await generate_react_component_from_html( html_content=request.html, api_key=api_key, image_base64=image_b64, - media_type=media_type + media_type=media_type, ) react_component = react_component.replace("```tsx", "").replace("```", "") - + return HtmlToReactResponse( success=True, react_component=react_component, - message="React component generated successfully" + message="React component generated successfully", ) - + except HTTPException: # Re-raise HTTP exceptions as-is raise @@ -541,8 +594,7 @@ async def convert_html_to_react(request: HtmlToReactRequest): # Log the full error for debugging print(f"Unexpected error during HTML to React processing: {str(e)}") raise HTTPException( - status_code=500, - detail=f"Error processing HTML to React: {str(e)}" + status_code=500, detail=f"Error processing HTML to React: {str(e)}" ) @@ -550,19 +602,21 @@ async def convert_html_to_react(request: HtmlToReactRequest): @HTML_EDIT_ROUTER.post("/", response_model=HtmlEditResponse) async def edit_html_with_images_endpoint( current_ui_image: UploadFile = File(..., description="Current UI image file"), - sketch_image: Optional[UploadFile] = File(None, description="Sketch/indication image file (optional)"), + sketch_image: Optional[UploadFile] = File( + None, description="Sketch/indication image file (optional)" + ), html: str = Form(..., description="Current HTML content to edit"), - prompt: str = Form(..., description="Text prompt describing the changes") + prompt: str = Form(..., description="Text prompt describing the changes"), ): """ Edit HTML content based on one or two uploaded images and a text prompt using Anthropic Claude API. - + Args: current_ui_image: Uploaded current UI image file sketch_image: Uploaded sketch/indication image file (optional) html: Current HTML content to edit (form data) prompt: Text prompt describing the changes (form data) - + Returns: HtmlEditResponse with edited HTML """ @@ -571,50 +625,45 @@ async def edit_html_with_images_endpoint( api_key = os.getenv("OPENAI_API_KEY") if not api_key: raise HTTPException( - status_code=500, - detail="OPENAI_API_KEY environment variable not set" + status_code=500, detail="OPENAI_API_KEY environment variable not set" ) - + # Validate inputs if not html or not html.strip(): - raise HTTPException( - status_code=400, - detail="HTML content cannot be empty" - ) - + raise HTTPException(status_code=400, detail="HTML content cannot be empty") + if not prompt or not prompt.strip(): - raise HTTPException( - status_code=400, - detail="Text prompt cannot be empty" - ) - + raise HTTPException(status_code=400, detail="Text prompt cannot be empty") + # Validate current UI image file - if not current_ui_image.content_type or not current_ui_image.content_type.startswith("image/"): + if ( + not current_ui_image.content_type + or not current_ui_image.content_type.startswith("image/") + ): raise HTTPException( - status_code=400, - detail="Current UI file must be an image" + status_code=400, detail="Current UI file must be an image" ) - + # Validate sketch image file only if provided - if sketch_image and (not sketch_image.content_type or not sketch_image.content_type.startswith("image/")): - raise HTTPException( - status_code=400, - detail="Sketch file must be an image" - ) - + if sketch_image and ( + not sketch_image.content_type + or not sketch_image.content_type.startswith("image/") + ): + raise HTTPException(status_code=400, detail="Sketch file must be an image") + # Read and encode current UI image to base64 current_ui_content = await current_ui_image.read() - current_ui_base64 = base64.b64encode(current_ui_content).decode('utf-8') - + current_ui_base64 = base64.b64encode(current_ui_content).decode("utf-8") + # Read and encode sketch image to base64 only if provided sketch_base64 = None if sketch_image: sketch_content = await sketch_image.read() - sketch_base64 = base64.b64encode(sketch_content).decode('utf-8') - + sketch_base64 = base64.b64encode(sketch_content).decode("utf-8") + # Use the content type from the uploaded files media_type = current_ui_image.content_type - + # Edit HTML using the function edited_html = await edit_html_with_images( current_ui_base64=current_ui_base64, @@ -622,17 +671,15 @@ async def edit_html_with_images_endpoint( media_type=media_type, html_content=html, prompt=prompt, - api_key=api_key + api_key=api_key, ) edited_html = edited_html.replace("```html", "").replace("```", "") - + return HtmlEditResponse( - success=True, - edited_html=edited_html, - message="HTML edited successfully" + success=True, edited_html=edited_html, message="HTML edited successfully" ) - + except HTTPException: # Re-raise HTTP exceptions as-is raise @@ -640,87 +687,81 @@ async def edit_html_with_images_endpoint( # Log the full error for debugging print(f"Unexpected error during HTML editing: {str(e)}") raise HTTPException( - status_code=500, - detail=f"Error processing HTML editing: {str(e)}" - ) + status_code=500, detail=f"Error processing HTML editing: {str(e)}" + ) # ENDPOINT 4: Save layouts for a presentation @LAYOUT_MANAGEMENT_ROUTER.post( - "/save-templates", + "/save-templates", response_model=SaveLayoutsResponse, responses={ 400: {"model": ErrorResponse, "description": "Validation error"}, - 500: {"model": ErrorResponse, "description": "Internal server error"} - } + 500: {"model": ErrorResponse, "description": "Internal server error"}, + }, ) async def save_layouts( - request: SaveLayoutsRequest, - session: AsyncSession = Depends(get_async_session) + request: SaveLayoutsRequest, session: AsyncSession = Depends(get_async_session) ): """ Save multiple layouts for presentations. - + Args: request: JSON request containing array of layout data session: Database session - + Returns: SaveLayoutsResponse with success status and count of saved layouts - + Raises: HTTPException: 400 for validation errors, 500 for server errors """ try: # Validate request data if not request.layouts: - raise HTTPException( - status_code=400, - detail="Layouts array cannot be empty" - ) - + raise HTTPException(status_code=400, detail="Layouts array cannot be empty") + if len(request.layouts) > 50: # Reasonable limit raise HTTPException( - status_code=400, - detail="Cannot save more than 50 layouts at once" + status_code=400, detail="Cannot save more than 50 layouts at once" ) - + saved_count = 0 - + for i, layout_data in enumerate(request.layouts): # Validate individual layout data - if not layout_data.presentation or not str(layout_data.presentation).strip(): + if ( + not layout_data.presentation + or not str(layout_data.presentation).strip() + ): raise HTTPException( status_code=400, - detail=f"Layout {i+1}: presentation_id cannot be empty" + detail=f"Layout {i+1}: presentation_id cannot be empty", ) - + if not layout_data.layout_id or not layout_data.layout_id.strip(): raise HTTPException( - status_code=400, - detail=f"Layout {i+1}: layout_id cannot be empty" + status_code=400, detail=f"Layout {i+1}: layout_id cannot be empty" ) - + if not layout_data.layout_name or not layout_data.layout_name.strip(): raise HTTPException( - status_code=400, - detail=f"Layout {i+1}: layout_name cannot be empty" + status_code=400, detail=f"Layout {i+1}: layout_name cannot be empty" ) - + if not layout_data.layout_code or not layout_data.layout_code.strip(): raise HTTPException( - status_code=400, - detail=f"Layout {i+1}: layout_code cannot be empty" + status_code=400, detail=f"Layout {i+1}: layout_code cannot be empty" ) - + # Check if layout already exists for this presentation and layout_id stmt = select(PresentationLayoutCodeModel).where( PresentationLayoutCodeModel.presentation == layout_data.presentation, - PresentationLayoutCodeModel.layout_id == layout_data.layout_id + PresentationLayoutCodeModel.layout_id == layout_data.layout_id, ) result = await session.execute(stmt) existing_layout = result.scalar_one_or_none() - + if existing_layout: # Update existing layout existing_layout.layout_name = layout_data.layout_name @@ -734,20 +775,20 @@ async def save_layouts( layout_id=layout_data.layout_id, layout_name=layout_data.layout_name, layout_code=layout_data.layout_code, - fonts=layout_data.fonts + fonts=layout_data.fonts, ) session.add(new_layout) - + saved_count += 1 - + await session.commit() - + return SaveLayoutsResponse( success=True, saved_count=saved_count, - message=f"Successfully saved {saved_count} layout(s)" + message=f"Successfully saved {saved_count} layout(s)", ) - + except HTTPException: # Re-raise HTTP exceptions as-is await session.rollback() @@ -757,34 +798,36 @@ async def save_layouts( print(f"Unexpected error saving layouts: {str(e)}") raise HTTPException( status_code=500, - detail=f"Internal server error while saving layouts: {str(e)}" + detail=f"Internal server error while saving layouts: {str(e)}", ) # ENDPOINT 5: Get layouts for a presentation @LAYOUT_MANAGEMENT_ROUTER.get( - "/get-templates/{presentation}", + "/get-templates/{presentation}", response_model=GetLayoutsResponse, responses={ 400: {"model": ErrorResponse, "description": "Invalid presentation ID"}, - 404: {"model": ErrorResponse, "description": "No layouts found for presentation"}, - 500: {"model": ErrorResponse, "description": "Internal server error"} - } + 404: { + "model": ErrorResponse, + "description": "No layouts found for presentation", + }, + 500: {"model": ErrorResponse, "description": "Internal server error"}, + }, ) async def get_layouts( - presentation: UUID, - session: AsyncSession = Depends(get_async_session) + presentation: UUID, session: AsyncSession = Depends(get_async_session) ): """ Retrieve all layouts for a specific presentation. - + Args: presentation: UUID of the presentation session: Database session - + Returns: GetLayoutsResponse with layouts data - + Raises: HTTPException: 404 if no layouts found, 400 for invalid UUID, 500 for server errors """ @@ -792,24 +835,23 @@ async def get_layouts( # Validate presentation_id format (basic UUID check) if not presentation or len(str(presentation).strip()) == 0: raise HTTPException( - status_code=400, - detail="Presentation ID cannot be empty" + status_code=400, detail="Presentation ID cannot be empty" ) - + # Query layouts for the given presentation_id stmt = select(PresentationLayoutCodeModel).where( PresentationLayoutCodeModel.presentation == presentation ) result = await session.execute(stmt) layouts_db = result.scalars().all() - + # Check if any layouts were found if not layouts_db: raise HTTPException( status_code=404, - detail=f"No layouts found for presentation ID: {presentation}" + detail=f"No layouts found for presentation ID: {presentation}", ) - + # Convert to response format layouts = [ LayoutData( @@ -817,18 +859,18 @@ async def get_layouts( layout_id=layout.layout_id, layout_name=layout.layout_name, layout_code=layout.layout_code, - fonts=layout.fonts + fonts=layout.fonts, ) for layout in layouts_db ] - + # Aggregate unique fonts across all layouts aggregated_fonts: set[str] = set() for layout in layouts_db: if layout.fonts: aggregated_fonts.update([f for f in layout.fonts if isinstance(f, str)]) fonts_list = sorted(list(aggregated_fonts)) if aggregated_fonts else None - + # Fetch template meta template_meta = await session.get(TemplateModel, presentation) template = None @@ -847,7 +889,7 @@ async def get_layouts( template=template, fonts=fonts_list, ) - + except HTTPException: # Re-raise HTTP exceptions as-is raise @@ -855,7 +897,7 @@ async def get_layouts( print(f"Error retrieving layouts for presentation {presentation}: {str(e)}") raise HTTPException( status_code=500, - detail=f"Internal server error while retrieving layouts: {str(e)}" + detail=f"Internal server error while retrieving layouts: {str(e)}", ) @@ -866,7 +908,10 @@ async def get_layouts( summary="Get all presentations with layout counts", description="Retrieve a summary of all presentations and the number of layouts in each", responses={ - 200: {"model": GetPresentationSummaryResponse, "description": "Presentations summary retrieved successfully"}, + 200: { + "model": GetPresentationSummaryResponse, + "description": "Presentations summary retrieved successfully", + }, 500: {"model": ErrorResponse, "description": "Internal server error"}, }, ) @@ -880,13 +925,13 @@ async def get_presentations_summary( # Query to get presentation_id, count of layouts, and MAX(updated_at) stmt = select( PresentationLayoutCodeModel.presentation, - func.count(PresentationLayoutCodeModel.id).label('layout_count'), - func.max(PresentationLayoutCodeModel.updated_at).label('last_updated_at') + func.count(PresentationLayoutCodeModel.id).label("layout_count"), + func.max(PresentationLayoutCodeModel.updated_at).label("last_updated_at"), ).group_by(PresentationLayoutCodeModel.presentation) - + result = await session.execute(stmt) presentation_data = result.all() - + # Convert to response format with template info if available presentations = [] for row in presentation_data: @@ -911,7 +956,7 @@ async def get_presentations_summary( # Calculate totals total_presentations = len(presentations) total_layouts = sum(p.layout_count for p in presentations) - + return GetPresentationSummaryResponse( success=True, presentations=presentations, @@ -919,13 +964,13 @@ async def get_presentations_summary( total_layouts=total_layouts, message=f"Retrieved {total_presentations} presentation(s) with {total_layouts} total layout(s)", ) - + except Exception as e: print(f"Error retrieving presentations summary: {str(e)}") raise HTTPException( status_code=500, - detail=f"Internal server error while retrieving presentations summary: {str(e)}" - ) + detail=f"Internal server error while retrieving presentations summary: {str(e)}", + ) @LAYOUT_MANAGEMENT_ROUTER.post( @@ -974,4 +1019,25 @@ async def create_template( raise except Exception as e: await session.rollback() - raise HTTPException(status_code=500, detail=f"Failed to save template: {str(e)}") \ No newline at end of file + raise HTTPException( + status_code=500, detail=f"Failed to save template: {str(e)}" + ) + + +@LAYOUT_MANAGEMENT_ROUTER.delete("/delete-templates/{template_id}", status_code=204) +async def delete_template( + template_id: UUID, + session: AsyncSession = Depends(get_async_session), +): + try: + await session.execute( + delete(TemplateModel).where(TemplateModel.id == template_id) + ) + await session.execute( + delete(PresentationLayoutCodeModel).where( + PresentationLayoutCodeModel.presentation == template_id, + ) + ) + await session.commit() + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete template") diff --git a/servers/fastapi/pyproject.toml b/servers/fastapi/pyproject.toml index 14240244..355d2d3b 100644 --- a/servers/fastapi/pyproject.toml +++ b/servers/fastapi/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "anthropic>=0.60.0", "asyncpg>=0.30.0", "chromadb>=1.0.15", + "dirtyjson>=1.0.8", "docling>=2.43.0", "fastapi[standard]>=0.116.1", "fastmcp>=2.11.0", diff --git a/servers/fastapi/services/documents_loader.py b/servers/fastapi/services/documents_loader.py index 9556f5b2..a45fde9d 100644 --- a/servers/fastapi/services/documents_loader.py +++ b/servers/fastapi/services/documents_loader.py @@ -96,11 +96,15 @@ class DocumentsLoader: return self.docling_service.parse_to_markdown(file_path) @classmethod - def get_page_images_from_pdf(cls, file_path: str, temp_dir: str): + def get_page_images_from_pdf(cls, file_path: str, temp_dir: str) -> List[str]: with pdfplumber.open(file_path) as pdf: + images = [] for page in pdf.pages: img = page.to_image(resolution=150) - img.save(os.path.join(temp_dir, f"page_{page.page_number}.png")) + image_path = os.path.join(temp_dir, f"page_{page.page_number}.png") + img.save(image_path) + images.append(image_path) + return images @classmethod async def get_page_images_from_pdf_async(cls, file_path: str, temp_dir: str): diff --git a/servers/fastapi/services/llm_client.py b/servers/fastapi/services/llm_client.py index 72205c93..0ecde95e 100644 --- a/servers/fastapi/services/llm_client.py +++ b/servers/fastapi/services/llm_client.py @@ -1,4 +1,5 @@ import asyncio +import dirtyjson import json from typing import AsyncGenerator, List, Optional from fastapi import HTTPException @@ -554,7 +555,7 @@ class LLMClient: ) if content: if depth == 0: - return json.loads(content) + return dict(dirtyjson.loads(content)) return content return None @@ -655,7 +656,7 @@ class LLMClient: ) if text_content: - return json.loads(text_content) + return dict(dirtyjson.loads(text_content)) return None async def _generate_anthropic_structured( diff --git a/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py b/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py index 35381fe5..cb044d4c 100644 --- a/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py +++ b/servers/fastapi/utils/llm_calls/generate_presentation_outlines.py @@ -39,6 +39,8 @@ def get_system_prompt( - 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."} + + **Search web to get latest information about the topic** """ diff --git a/servers/fastapi/utils/llm_client_error_handler.py b/servers/fastapi/utils/llm_client_error_handler.py index a4371e8c..7e4c915b 100644 --- a/servers/fastapi/utils/llm_client_error_handler.py +++ b/servers/fastapi/utils/llm_client_error_handler.py @@ -2,9 +2,11 @@ from fastapi import HTTPException from anthropic import APIError as AnthropicAPIError from openai import APIError as OpenAIAPIError from google.genai.errors import APIError as GoogleAPIError +import traceback def handle_llm_client_exceptions(e: Exception) -> HTTPException: + traceback.print_exc() if isinstance(e, OpenAIAPIError): return HTTPException(status_code=500, detail=f"OpenAI API error: {e.message}") if isinstance(e, GoogleAPIError): diff --git a/servers/fastapi/uv.lock b/servers/fastapi/uv.lock index 4e19c02b..d3dcccac 100644 --- a/servers/fastapi/uv.lock +++ b/servers/fastapi/uv.lock @@ -471,6 +471,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, ] +[[package]] +name = "dirtyjson" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/04/d24f6e645ad82ba0ef092fa17d9ef7a21953781663648a01c9371d9e8e98/dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd", size = 30782, upload-time = "2022-11-28T23:32:33.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/69/1bcf70f81de1b4a9f21b3a62ec0c83bdff991c88d6cc2267d02408457e88/dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53", size = 25197, upload-time = "2022-11-28T23:32:31.219Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -1908,6 +1917,7 @@ dependencies = [ { name = "anthropic" }, { name = "asyncpg" }, { name = "chromadb" }, + { name = "dirtyjson" }, { name = "docling" }, { name = "fastapi", extra = ["standard"] }, { name = "fastmcp" }, @@ -1930,6 +1940,7 @@ requires-dist = [ { name = "anthropic", specifier = ">=0.60.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "chromadb", specifier = ">=1.0.15" }, + { name = "dirtyjson", specifier = ">=1.0.8" }, { name = "docling", specifier = ">=2.43.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.116.1" }, { name = "fastmcp", specifier = ">=2.11.0" },