Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 105 additions & 10 deletions servers/fastapi/api/v1/ppt/endpoints/presentation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import colorsys
from copy import deepcopy
from datetime import datetime
import hashlib
import json
Expand Down Expand Up @@ -141,10 +142,10 @@ def _build_theme_payload_from_palette(color_palette, description: str) -> dict:


def _fallback_ai_colors_from_topic(topic: str) -> dict:
"""Create a deterministic, topic-seeded base palette without domain-specific hardcoding."""
"""Create a topic-aware fallback palette with slight variation across runs."""
seed = hashlib.sha256((topic or "presentation").strip().lower().encode("utf-8")).digest()
hue = (seed[0] / 255.0)
dark_mode = (seed[1] % 2) == 0
hue = ((seed[0] / 255.0) + random.SystemRandom().uniform(-0.06, 0.06)) % 1.0
dark_mode = (seed[1] % 2) == 0 if random.SystemRandom().random() > 0.25 else not ((seed[1] % 2) == 0)

primary = _to_hex_from_rgb(colorsys.hls_to_rgb(hue, 0.46, 0.62))
accent_1 = _to_hex_from_rgb(colorsys.hls_to_rgb((hue + 0.08) % 1.0, 0.56, 0.52))
Expand All @@ -169,6 +170,53 @@ def _fallback_ai_colors_from_topic(topic: str) -> dict:
}


async def _build_auto_theme_layout_model(topic: str) -> PresentationLayoutModel:
"""Build a mixed layout model for auto-theme mode instead of forcing a single prebuilt group."""
candidate_groups = [
"neo-general",
"neo-modern",
"neo-standard",
"neo-swift",
"neo-brutalist",
"neo-brutalist-fancy",
"soft-bloom",
"velvet-haze",
*DEFAULT_TEMPLATES,
]

layouts_pool = []
for group in candidate_groups:
try:
group_layout = await get_layout_by_name(group)
for slide in group_layout.slides:
slide_copy = deepcopy(slide)
slide_copy.id = f"auto-{group}:{slide.id}"
if slide_copy.description:
slide_copy.description = f"{slide_copy.description} (source: {group})"
else:
slide_copy.description = f"Auto-mixed layout (source: {group})"
layouts_pool.append(slide_copy)
except Exception:
# Ignore missing groups and continue building from available ones.
continue

if not layouts_pool:
raise HTTPException(status_code=500, detail="No layouts available for auto-theme generation")

random.SystemRandom().shuffle(layouts_pool)

# Keep a rich but controlled pool to reduce repetition while avoiding over-complexity.
target_size = min(max(12, len(layouts_pool) // 3), 24)
mixed_slides = layouts_pool[:target_size]

topic_hash = hashlib.sha256((topic or "presentation").encode("utf-8")).hexdigest()[:8]
return PresentationLayoutModel(
name=f"auto-theme-{topic_hash}-{uuid.uuid4().hex[:6]}",
ordered=False,
slides=mixed_slides,
)


@PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides])
async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_session)):
presentations_with_slides = []
Expand Down Expand Up @@ -250,7 +298,10 @@ async def create_presentation(
if auto_theme and not theme:
# Generate dynamic theme from topic
try:
ai_colors = await generate_theme_from_topic(content)
ai_colors = await generate_theme_from_topic(
content,
variation_seed=uuid.uuid4().hex[:8],
)
color_palette = generate_color_palette(
ai_colors.get("primary"),
ai_colors.get("background"),
Expand Down Expand Up @@ -305,7 +356,8 @@ async def create_presentation(
async def prepare_presentation(
presentation_id: Annotated[uuid.UUID, Body()],
outlines: Annotated[List[SlideOutlineModel], Body()],
layout: Annotated[PresentationLayoutModel, Body()],
layout: Annotated[Optional[PresentationLayoutModel], Body()] = None,
auto_theme: Annotated[bool, Body()] = False,
title: Annotated[Optional[str], Body()] = None,
sql_session: AsyncSession = Depends(get_async_session),
):
Expand All @@ -318,6 +370,12 @@ async def prepare_presentation(

presentation_outline_model = PresentationOutlineModel(slides=outlines)

if auto_theme:
layout = await _build_auto_theme_layout_model(presentation.content)

if not layout:
raise HTTPException(status_code=400, detail="Layout is required")

total_slide_layouts = len(layout.slides)
total_outlines = len(outlines)

Expand Down Expand Up @@ -609,10 +667,8 @@ async def check_if_api_request_is_valid(
detail="Number of slides must be greater than 0",
)

if request.auto_theme:
request.template = "neo-general"
# Checking if template is valid
elif request.template not in DEFAULT_TEMPLATES:
# Checking if template is valid (auto-theme builds a dynamic mixed layout)
if not request.auto_theme and request.template not in DEFAULT_TEMPLATES:
request.template = request.template.lower()
if not request.template.startswith("custom-"):
raise HTTPException(
Expand Down Expand Up @@ -641,6 +697,40 @@ async def generate_presentation_handler(
):
try:
using_slides_markdown = False
generated_theme = None

if request.auto_theme:
try:
ai_colors = await generate_theme_from_topic(
request.content,
variation_seed=uuid.uuid4().hex[:8],
)
color_palette = generate_color_palette(
ai_colors.get("primary"),
ai_colors.get("background"),
ai_colors.get("accent_1"),
ai_colors.get("accent_2"),
ai_colors.get("text_1"),
ai_colors.get("text_2"),
)
generated_theme = _build_theme_payload_from_palette(
color_palette,
"Theme generated automatically from presentation topic",
)
except Exception:
ai_colors = _fallback_ai_colors_from_topic(request.content)
color_palette = generate_color_palette(
ai_colors.get("primary"),
ai_colors.get("background"),
ai_colors.get("accent_1"),
ai_colors.get("accent_2"),
ai_colors.get("text_1"),
ai_colors.get("text_2"),
)
generated_theme = _build_theme_payload_from_palette(
color_palette,
"Fallback AI theme generated from topic",
)

if request.slides_markdown:
using_slides_markdown = True
Expand Down Expand Up @@ -732,7 +822,11 @@ async def generate_presentation_handler(
print(f"Generated {total_outlines} outlines for the presentation")

# Parse Layouts
layout_model = await get_layout_by_name(request.template)
layout_model = (
await _build_auto_theme_layout_model(request.content)
if request.auto_theme
else await get_layout_by_name(request.template)
)
total_slide_layouts = len(layout_model.slides)

# Generate Structure
Expand Down Expand Up @@ -804,6 +898,7 @@ async def generate_presentation_handler(
outlines=presentation_outlines.model_dump(),
layout=layout_model.model_dump(),
structure=presentation_structure.model_dump(),
theme=generated_theme,
tone=request.tone.value,
verbosity=request.verbosity.value,
instructions=request.instructions,
Expand Down
7 changes: 6 additions & 1 deletion servers/fastapi/api/v1/ppt/endpoints/theme_generate.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Optional
import uuid

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
Expand Down Expand Up @@ -34,7 +35,11 @@ async def generate_theme_from_topic_endpoint(
) -> ThemeData:
"""Generate a theme color palette using AI based on the presentation topic."""
try:
ai_colors = await generate_theme_from_topic(request.topic, request.mood)
ai_colors = await generate_theme_from_topic(
request.topic,
request.mood,
variation_seed=uuid.uuid4().hex[:8],
)
except Exception:
raise HTTPException(
status_code=500,
Expand Down
2 changes: 1 addition & 1 deletion servers/fastapi/constants/presentation.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"]
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift", "professional-pitch", "educational-science", "educational-social-science", "tech-ai-red"]
13 changes: 12 additions & 1 deletion servers/fastapi/utils/llm_calls/generate_theme_from_topic.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,19 @@
def _get_theme_generation_messages(
topic: str,
mood: Optional[str] = None,
variation_seed: Optional[str] = None,
):
mood_instruction = ""
if mood:
mood_instruction = f"\nThe user wants a '{mood}' mood/style for the theme."

variation_instruction = ""
if variation_seed:
variation_instruction = (
"\nDesign a fresh variant for this request and avoid reusing your most common default palette patterns."
f"\nVariation token: {variation_seed}"
)

return [
LLMSystemMessage(
content=f"""You are a professional presentation designer and color theory expert.
Expand All @@ -71,10 +79,12 @@ def _get_theme_generation_messages(
- text_2 should contrast well against the primary color.

4. **Professionalism**: Avoid overly saturated or clashing colors. Aim for a polished, premium look.
4.1 **Restrained palette first**: Keep the visual system focused and calm by default (one dominant, one support, one accent). Avoid rainbow-like palettes unless explicitly requested.

5. **Background choice**: Default to light backgrounds (#F8FAFC to #FFFFFF range) unless the topic strongly suggests a dark theme (e.g., night photography, space, cybersecurity, gaming).
6. **Topic match priority**: Prioritize topic relevance over generic trendy palettes. If topic implies ocean/space/night, use blue-dark families first.
{mood_instruction}
{variation_instruction}

Return exactly 6 hex color values.""",
),
Expand All @@ -87,6 +97,7 @@ def _get_theme_generation_messages(
async def generate_theme_from_topic(
topic: str,
mood: Optional[str] = None,
variation_seed: Optional[str] = None,
) -> dict:
"""
Uses the LLM to generate a color palette based on the presentation topic.
Expand All @@ -98,7 +109,7 @@ async def generate_theme_from_topic(
try:
response = await client.generate_structured(
model=model,
messages=_get_theme_generation_messages(topic, mood),
messages=_get_theme_generation_messages(topic, mood, variation_seed),
response_format=THEME_GENERATION_SCHEMA,
strict=True,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,18 @@ export const usePresentationGeneration = (
});

try {
const templateToUse = selectedTemplate ?? (isAutoTheme ? "general" : null);
const templateToUse = selectedTemplate ?? null;
if (!templateToUse) {
setActiveTab(TABS.LAYOUTS);
return;
if (!isAutoTheme) {
setActiveTab(TABS.LAYOUTS);
return;
}
}

let layout;

// Check if it's a custom template (string)
if (typeof templateToUse === 'string') {
if (templateToUse && typeof templateToUse === 'string') {
const builtInTemplate = templates.find(t => t.id === templateToUse);
if (builtInTemplate) {
layout = {
Expand Down Expand Up @@ -147,7 +149,7 @@ export const usePresentationGeneration = (
}))
};
}
} else {
} else if (templateToUse) {
// Built-in template
layout = {
name: templateToUse.id,
Expand All @@ -167,6 +169,7 @@ export const usePresentationGeneration = (
presentation_id: presentationId,
outlines: outlines,
layout: layout,
auto_theme: isAutoTheme,
});

if (response) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from "react";
import * as z from "zod";

export const layoutId = "educational-science-comparison";
export const layoutName = "Comparison Study";
export const layoutDescription = "Text-priority comparison layout for contrasting two scientific ideas, models, or outcomes.";

export const Schema = z.object({
title: z.string().min(5).max(70).default("Mitosis vs Meiosis"),
leftTitle: z.string().min(3).max(30).default("Mitosis"),
rightTitle: z.string().min(3).max(30).default("Meiosis"),
leftPoints: z.array(z.string().min(6).max(90)).max(5).default(["One cell division", "Produces 2 identical cells", "Used for growth and repair"]),
rightPoints: z.array(z.string().min(6).max(90)).max(5).default(["Two cell divisions", "Produces 4 non-identical cells", "Used for gamete formation"]),
summary: z.string().min(20).max(180).default("Both processes are essential, but they serve different biological purposes in organisms."),
});

type SchemaType = z.infer<typeof Schema>;

export default function EduScienceComparisonLayout({ data }: { data: Partial<SchemaType> }) {
return (
<div className="relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white z-20 mx-auto overflow-hidden p-12"
style={{ fontFamily: "var(--body-font-family, Inter)" }}>
<h1 className="text-4xl font-bold text-slate-900">{data.title}</h1>
<div className="grid grid-cols-2 gap-5 mt-7">
<section className="rounded-xl border border-slate-200 p-5 bg-slate-50">
<h2 className="text-2xl font-semibold mb-3" style={{ color: "var(--primary-color,#2563EB)" }}>{data.leftTitle}</h2>
<ul className="space-y-2 text-slate-700 text-base leading-7">{(data.leftPoints ?? []).map((p, i) => <li key={i}>• {p}</li>)}</ul>
</section>
<section className="rounded-xl border border-slate-200 p-5 bg-slate-50">
<h2 className="text-2xl font-semibold mb-3" style={{ color: "#0EA5E9" }}>{data.rightTitle}</h2>
<ul className="space-y-2 text-slate-700 text-base leading-7">{(data.rightPoints ?? []).map((p, i) => <li key={i}>• {p}</li>)}</ul>
</section>
</div>
<p className="mt-5 text-lg leading-7 text-slate-700 max-w-5xl">{data.summary}</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from "react";
import * as z from "zod";

export const layoutId = "educational-science-concepts";
export const layoutName = "Core Concepts";
export const layoutDescription = "Text-heavy concept slide with structured explanation blocks and minimal decorative shapes.";

export const Schema = z.object({
title: z.string().min(5).max(70).default("Core Concepts of Photosynthesis"),
explanation: z.string().min(60).max(420).default("Photosynthesis is the process by which green plants convert light energy into chemical energy. Chlorophyll absorbs sunlight and helps create glucose from carbon dioxide and water."),
points: z.array(z.object({ heading: z.string().min(4).max(40), detail: z.string().min(20).max(140) })).max(6).default([
{ heading: "Input", detail: "Plants use sunlight, carbon dioxide, and water as input materials." },
{ heading: "Output", detail: "Glucose and oxygen are produced as outputs." },
{ heading: "Location", detail: "The process mainly takes place in chloroplasts." },
]),
});

type SchemaType = z.infer<typeof Schema>;

export default function EduScienceConceptLayout({ data }: { data: Partial<SchemaType> }) {
return (
<div className="relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white z-20 mx-auto overflow-hidden p-12"
style={{ fontFamily: "var(--body-font-family, Inter)" }}>
<div className="absolute right-8 top-8 w-24 h-24 rounded-2xl opacity-10 rotate-12" style={{ background: "var(--primary-color,#2563EB)" }} />
<div className="h-full grid grid-rows-[auto_auto_1fr] gap-5">
<h1 className="text-4xl font-bold text-slate-900">{data.title}</h1>
<p className="text-lg leading-8 text-slate-700 max-w-6xl">{data.explanation}</p>
<div className="grid grid-cols-2 gap-4 content-start">
{(data.points ?? []).map((p, i) => (
<div key={i} className="rounded-xl border border-slate-200 p-4 bg-white">
<h3 className="text-lg font-semibold text-slate-900">{p.heading}</h3>
<p className="text-sm leading-6 text-slate-600 mt-1">{p.detail}</p>
</div>
))}
</div>
</div>
</div>
);
}
Loading
Loading