diff --git "a/app.py" "b/app.py" new file mode 100644--- /dev/null +++ "b/app.py" @@ -0,0 +1,2749 @@ +#!/usr/bin/env python + +import os +import re +import json +import requests +from collections.abc import Iterator +from threading import Thread +import tempfile +import random +from typing import Dict, List, Tuple, Optional +import shutil +import concurrent.futures + +import gradio as gr +from loguru import logger +import pandas as pd +import PyPDF2 +from PIL import Image +from gradio_client import Client +import time + +# python-pptx 라이브러리 확인 +try: + from pptx import Presentation + from pptx.util import Inches, Pt + from pptx.enum.text import PP_ALIGN, MSO_ANCHOR + from pptx.dml.color import RGBColor + from pptx.enum.shapes import MSO_SHAPE + from pptx.chart.data import CategoryChartData + from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION + PPTX_AVAILABLE = True +except ImportError: + PPTX_AVAILABLE = False + logger.warning("python-pptx 라이브러리가 설치되지 않았습니다. pip install python-pptx") + +############################################################################## +# API Configuration +############################################################################## +FRIENDLI_TOKEN = os.environ.get("FRIENDLI_TOKEN") +if not FRIENDLI_TOKEN: + raise ValueError("Please set FRIENDLI_TOKEN environment variable") + +FRIENDLI_MODEL_ID = "dep89a2fld32mcm" +FRIENDLI_API_URL = "https://api.friendli.ai/dedicated/v1/chat/completions" + +# SERPHouse API key +SERPHOUSE_API_KEY = os.getenv("SERPHOUSE_API_KEY", "") +if not SERPHOUSE_API_KEY: + logger.warning("SERPHOUSE_API_KEY not set. Web search functionality will be limited.") + +############################################################################## +# AI Image Generation API Configuration - 3D Style +############################################################################## +AI_IMAGE_API_URL = "http://211.233.58.201:7971/" +AI_IMAGE_ENABLED = False +ai_image_client = None + +def initialize_ai_image_api(): + """AI 이미지 생성 API 초기화 (3D 스타일)""" + global AI_IMAGE_ENABLED, ai_image_client + + try: + logger.info("Connecting to AI image generation API (3D style)...") + ai_image_client = Client(AI_IMAGE_API_URL) + AI_IMAGE_ENABLED = True + logger.info("AI image generation API (3D style) connected successfully") + return True + except Exception as e: + logger.error(f"Failed to connect to AI image API: {e}") + AI_IMAGE_ENABLED = False + return False + +############################################################################## +# AI Image Generation API Configuration - FLUX API +############################################################################## +FLUX_API_URL = "http://211.233.58.201:7896" +FLUX_API_ENABLED = False +flux_api_client = None + +def initialize_flux_api(): + """FLUX API 초기화""" + global FLUX_API_ENABLED, flux_api_client + + try: + logger.info("Connecting to FLUX API...") + flux_api_client = Client(FLUX_API_URL) + FLUX_API_ENABLED = True + logger.info("FLUX API connected successfully") + return True + except Exception as e: + logger.error(f"Failed to connect to FLUX API: {e}") + FLUX_API_ENABLED = False + return False + +############################################################################## +# Diagram Generation API Configuration +############################################################################## +DIAGRAM_API_URL = "http://211.233.58.201:7860" # ChartGPT API URL +DIAGRAM_API_ENABLED = False +diagram_api_client = None + +def initialize_diagram_api(): + """다이어그램 생성 API 초기화""" + global DIAGRAM_API_ENABLED, diagram_api_client + + try: + logger.info("Connecting to Diagram Generation API...") + diagram_api_client = Client(DIAGRAM_API_URL) + DIAGRAM_API_ENABLED = True + logger.info("Diagram API connected successfully") + return True + except Exception as e: + logger.error(f"Failed to connect to Diagram API: {e}") + DIAGRAM_API_ENABLED = False + return False + +############################################################################## +# Design Themes and Color Schemes +############################################################################## +DESIGN_THEMES = { + "professional": { + "name": "프로페셔널", + "colors": { + "primary": RGBColor(46, 134, 171), # #2E86AB + "secondary": RGBColor(162, 59, 114), # #A23B72 + "accent": RGBColor(241, 143, 1), # #F18F01 + "background": RGBColor(250, 250, 250), # #FAFAFA - Lighter background + "text": RGBColor(44, 44, 44), # #2C2C2C - Darker text for better contrast + }, + "fonts": { + "title": "Arial", + "subtitle": "Arial", + "body": "Calibri" + } + }, + "modern": { + "name": "모던", + "colors": { + "primary": RGBColor(114, 9, 183), # #7209B7 + "secondary": RGBColor(247, 37, 133), # #F72585 + "accent": RGBColor(76, 201, 240), # #4CC9F0 + "background": RGBColor(252, 252, 252), # #FCFCFC - Very light background + "text": RGBColor(40, 40, 40), # #282828 - Dark text + }, + "fonts": { + "title": "Arial", + "subtitle": "Arial", + "body": "Helvetica" + } + }, + "nature": { + "name": "자연", + "colors": { + "primary": RGBColor(45, 106, 79), # #2D6A4F + "secondary": RGBColor(82, 183, 136), # #52B788 + "accent": RGBColor(181, 233, 185), # #B5E9B9 - Softer accent + "background": RGBColor(248, 252, 248), # #F8FCF8 - Light green tint + "text": RGBColor(27, 38, 44), # #1B262C + }, + "fonts": { + "title": "Georgia", + "subtitle": "Verdana", + "body": "Calibri" + } + }, + "creative": { + "name": "크리에이티브", + "colors": { + "primary": RGBColor(255, 0, 110), # #FF006E + "secondary": RGBColor(251, 86, 7), # #FB5607 + "accent": RGBColor(255, 190, 11), # #FFBE0B + "background": RGBColor(255, 248, 240), # #FFF8F0 - Light warm background + "text": RGBColor(33, 33, 33), # #212121 - Dark text on light bg + }, + "fonts": { + "title": "Impact", + "subtitle": "Arial", + "body": "Segoe UI" + } + }, + "minimal": { + "name": "미니멀", + "colors": { + "primary": RGBColor(55, 55, 55), # #373737 - Softer than pure black + "secondary": RGBColor(120, 120, 120), # #787878 + "accent": RGBColor(0, 122, 255), # #007AFF - Blue accent + "background": RGBColor(252, 252, 252), # #FCFCFC + "text": RGBColor(33, 33, 33), # #212121 + }, + "fonts": { + "title": "Helvetica", + "subtitle": "Helvetica", + "body": "Arial" + } + } +} + +############################################################################## +# Slide Layout Types +############################################################################## +SLIDE_LAYOUTS = { + "title": 0, # 제목 슬라이드 + "title_content": 1, # 제목과 내용 + "section_header": 2, # 섹션 헤더 + "two_content": 3, # 2단 레이아웃 + "comparison": 4, # 비교 레이아웃 + "title_only": 5, # 제목만 + "blank": 6 # 빈 슬라이드 +} + +############################################################################## +# Emoji Bullet Points Mapping +############################################################################## +def has_emoji(text: str) -> bool: + """Check if text already contains emoji""" + # Check for common emoji unicode ranges + for char in text[:3]: # Check first 3 characters + code = ord(char) + # Common emoji ranges + if (0x1F300 <= code <= 0x1F9FF) or \ + (0x2600 <= code <= 0x26FF) or \ + (0x2700 <= code <= 0x27BF) or \ + (0x1F000 <= code <= 0x1F02F) or \ + (0x1F0A0 <= code <= 0x1F0FF) or \ + (0x1F100 <= code <= 0x1F1FF): + return True + return False + +def get_emoji_for_content(text: str) -> str: + """Get relevant emoji based on content""" + text_lower = text.lower() + + # Technology + if any(word in text_lower for word in ['ai', '인공지능', 'ml', '머신러닝', '딥러닝', 'deep learning']): + return '🤖' + elif any(word in text_lower for word in ['데이터', 'data', '분석', 'analysis', '통계']): + return '📊' + elif any(word in text_lower for word in ['코드', 'code', '프로그래밍', 'programming', '개발']): + return '💻' + elif any(word in text_lower for word in ['클라우드', 'cloud', '서버', 'server']): + return '☁️' + elif any(word in text_lower for word in ['보안', 'security', '안전', 'safety']): + return '🔒' + elif any(word in text_lower for word in ['네트워크', 'network', '연결', 'connection', '인터넷']): + return '🌐' + elif any(word in text_lower for word in ['모바일', 'mobile', '스마트폰', 'smartphone', '앱']): + return '📱' + + # Business + elif any(word in text_lower for word in ['성장', 'growth', '증가', 'increase', '상승']): + return '📈' + elif any(word in text_lower for word in ['목표', 'goal', 'target', '타겟', '목적']): + return '🎯' + elif any(word in text_lower for word in ['돈', 'money', '비용', 'cost', '예산', 'budget', '수익']): + return '💰' + elif any(word in text_lower for word in ['팀', 'team', '협업', 'collaboration', '협력']): + return '👥' + elif any(word in text_lower for word in ['시간', 'time', '일정', 'schedule', '기한']): + return '⏰' + elif any(word in text_lower for word in ['아이디어', 'idea', '창의', 'creative', '혁신']): + return '💡' + elif any(word in text_lower for word in ['전략', 'strategy', '계획', 'plan']): + return '📋' + elif any(word in text_lower for word in ['성공', 'success', '달성', 'achieve']): + return '🏆' + + # Education + elif any(word in text_lower for word in ['학습', 'learning', '교육', 'education', '공부']): + return '📚' + elif any(word in text_lower for word in ['연구', 'research', '조사', 'study', '실험']): + return '🔬' + elif any(word in text_lower for word in ['문서', 'document', '보고서', 'report']): + return '📄' + elif any(word in text_lower for word in ['정보', 'information', '지식', 'knowledge']): + return '📖' + + # Communication + elif any(word in text_lower for word in ['소통', 'communication', '대화', 'conversation']): + return '💬' + elif any(word in text_lower for word in ['이메일', 'email', '메일', 'mail']): + return '📧' + elif any(word in text_lower for word in ['전화', 'phone', 'call', '통화']): + return '📞' + elif any(word in text_lower for word in ['회의', 'meeting', '미팅', '컨퍼런스']): + return '👔' + + # Nature/Environment + elif any(word in text_lower for word in ['환경', 'environment', '자연', 'nature']): + return '🌱' + elif any(word in text_lower for word in ['지속가능', 'sustainable', '친환경', 'eco']): + return '♻️' + elif any(word in text_lower for word in ['에너지', 'energy', '전력', 'power']): + return '⚡' + elif any(word in text_lower for word in ['지구', 'earth', '세계', 'world']): + return '🌍' + + # Process/Steps + elif any(word in text_lower for word in ['프로세스', 'process', '절차', 'procedure', '단계']): + return '🔄' + elif any(word in text_lower for word in ['체크', 'check', '확인', 'verify', '검증']): + return '✅' + elif any(word in text_lower for word in ['주의', 'warning', '경고', 'caution']): + return '⚠️' + elif any(word in text_lower for word in ['중요', 'important', '핵심', 'key', '필수']): + return '⭐' + elif any(word in text_lower for word in ['질문', 'question', '문의', 'ask']): + return '❓' + elif any(word in text_lower for word in ['해결', 'solution', '답', 'answer']): + return '💯' + + # Actions + elif any(word in text_lower for word in ['시작', 'start', '출발', 'begin']): + return '🚀' + elif any(word in text_lower for word in ['완료', 'complete', '종료', 'finish']): + return '🏁' + elif any(word in text_lower for word in ['개선', 'improve', '향상', 'enhance']): + return '🔧' + elif any(word in text_lower for word in ['변화', 'change', '변경', 'modify']): + return '🔄' + + # Industries + elif any(word in text_lower for word in ['의료', 'medical', '병원', 'hospital', '건강']): + return '🏥' + elif any(word in text_lower for word in ['금융', 'finance', '은행', 'bank']): + return '🏦' + elif any(word in text_lower for word in ['제조', 'manufacturing', '공장', 'factory']): + return '🏭' + elif any(word in text_lower for word in ['농업', 'agriculture', '농장', 'farm']): + return '🌾' + + # Emotion/Status + elif any(word in text_lower for word in ['행복', 'happy', '기쁨', 'joy']): + return '😊' + elif any(word in text_lower for word in ['위험', 'danger', 'risk', '리스크']): + return '⚡' + elif any(word in text_lower for word in ['새로운', 'new', '신규', 'novel']): + return '✨' + + # Numbers + elif text_lower.startswith(('첫째', 'first', '1.', '첫번째', '첫 번째')): + return '1️⃣' + elif text_lower.startswith(('둘째', 'second', '2.', '두번째', '두 번째')): + return '2️⃣' + elif text_lower.startswith(('셋째', 'third', '3.', '세번째', '세 번째')): + return '3️⃣' + elif text_lower.startswith(('넷째', 'fourth', '4.', '네번째', '네 번째')): + return '4️⃣' + elif text_lower.startswith(('다섯째', 'fifth', '5.', '다섯번째', '다섯 번째')): + return '5️⃣' + + # Default + else: + return '▶️' + +############################################################################## +# Diagram Type Detection +############################################################################## +def detect_diagram_type(title: str, content: str) -> Optional[str]: + """슬라이드 내용을 분석하여 적절한 다이어그램 타입 결정""" + combined_text = f"{title} {content}".lower() + + # Process Flow keywords + if any(word in combined_text for word in ['프로세스', 'process', '절차', 'procedure', '단계', 'step', 'flow', '흐름', '워크플로우', 'workflow']): + return "Process Flow" + + # WBS keywords + elif any(word in combined_text for word in ['wbs', '작업분해', '프로젝트', 'project', '업무분해', 'breakdown', '구조도']): + return "WBS Diagram" + + # Concept Map keywords + elif any(word in combined_text for word in ['개념', 'concept', '관계', 'relationship', '연관', 'connection', '마인드맵', 'mindmap']): + return "Concept Map" + + # Radial Diagram keywords + elif any(word in combined_text for word in ['중심', 'central', '방사형', 'radial', '핵심', 'core', '주요', 'main']): + return "Radial Diagram" + + # Synoptic Chart keywords + elif any(word in combined_text for word in ['개요', 'overview', '전체', 'overall', '요약', 'summary', '시놉틱', 'synoptic']): + return "Synoptic Chart" + + return None + +############################################################################## +# Generate Diagram JSON using LLM +############################################################################## +def generate_diagram_json(title: str, content: str, diagram_type: str) -> Optional[str]: + """LLM을 사용하여 다이어그램용 JSON 생성""" + if not FRIENDLI_TOKEN: + return None + + # 다이어그램 타입별 JSON 구조 가이드 + json_guides = { + "Concept Map": """Generate a JSON for a concept map with the EXACT following structure: +{ + "central_node": "Main Topic", + "nodes": [ + { + "id": "node1", + "label": "First Concept", + "relationship": "is part of", + "subnodes": [ + { + "id": "node1_1", + "label": "Sub Concept 1", + "relationship": "includes", + "subnodes": [] + } + ] + } + ] +}""", + "Process Flow": """Generate a JSON for a process flow diagram with the EXACT following structure: +{ + "start_node": "Start Process", + "nodes": [ + {"id": "step1", "label": "First Step", "type": "process"}, + {"id": "step2", "label": "Decision Point", "type": "decision"}, + {"id": "end", "label": "End Process", "type": "end"} + ], + "connections": [ + {"from": "start_node", "to": "step1", "label": "Begin"}, + {"from": "step1", "to": "step2", "label": "Next"}, + {"from": "step2", "to": "end", "label": "Complete"} + ] +}""", + "WBS Diagram": """Generate a JSON for a WBS diagram with the EXACT following structure: +{ + "project_title": "Project Name", + "phases": [ + { + "id": "phase1", + "label": "Phase 1", + "tasks": [ + { + "id": "task1_1", + "label": "Task 1.1", + "subtasks": [] + } + ] + } + ] +}""", + "Radial Diagram": """Generate a JSON for a radial diagram with the EXACT following structure: +{ + "central_node": "Central Concept", + "nodes": [ + { + "id": "branch1", + "label": "Branch 1", + "relationship": "connected to", + "subnodes": [] + } + ] +}""", + "Synoptic Chart": """Generate a JSON for a synoptic chart with the EXACT following structure: +{ + "central_node": "Chart Title", + "nodes": [ + { + "id": "phase1", + "label": "Phase 1 Name", + "relationship": "starts with", + "subnodes": [] + } + ] +}""" + } + + system_prompt = f"""You are a helpful assistant that generates JSON structures for diagrams. +{json_guides.get(diagram_type, '')} + +Important rules: +1. Generate ONLY valid JSON without any explanation or markdown formatting +2. The JSON must follow the EXACT structure shown above +3. Create content based on the provided title and content +4. Use the user's language (Korean or English) for the content values +5. Keep it simple with 3-5 main nodes/steps""" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Create a {diagram_type} JSON for:\nTitle: {title}\nContent: {content}"} + ] + + headers = { + "Authorization": f"Bearer {FRIENDLI_TOKEN}", + "Content-Type": "application/json" + } + + payload = { + "model": FRIENDLI_MODEL_ID, + "messages": messages, + "max_tokens": 1000, + "temperature": 0.7, + "stream": False + } + + try: + response = requests.post(FRIENDLI_API_URL, headers=headers, json=payload, timeout=30) + if response.status_code == 200: + response_data = response.json() + if 'choices' in response_data and len(response_data['choices']) > 0: + content = response_data['choices'][0]['message']['content'] + # Extract JSON from response + content = content.strip() + if content.startswith("```json"): + content = content[7:] + if content.startswith("```"): + content = content[3:] + if content.endswith("```"): + content = content[:-3] + + # Validate JSON + json.loads(content) # This will raise exception if invalid + return content + except Exception as e: + logger.error(f"Error generating diagram JSON: {e}") + + return None + +############################################################################## +# Generate Diagram using API +############################################################################## +def generate_diagram_via_api(json_data: str, diagram_type: str) -> Optional[str]: + """다이어그램 API를 통해 다이어그램 생성""" + if not DIAGRAM_API_ENABLED or not diagram_api_client: + return None + + try: + # API 호출 + result = diagram_api_client.predict( + prompt_input=f"Generate {diagram_type}", # 프롬프트 + diagram_type_select=diagram_type, # 다이어그램 타입 + design_type_select="None", # 디자인 타입은 None + output_format_radio="png", # PNG 형식 + use_search_checkbox=False, # 검색 사용 안함 + api_name="/generate_with_llm" + ) + + # 결과에서 이미지 경로 추출 + if isinstance(result, tuple) and len(result) > 0: + image_path = result[0] + if image_path and os.path.exists(image_path): + # 임시 파일로 복사 + with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp: + shutil.copy2(image_path, tmp.name) + return tmp.name + + return None + + except Exception as e: + logger.error(f"Failed to generate diagram via API: {e}") + return None + +############################################################################## +# FLUX Image Generation Functions +############################################################################## +def generate_flux_prompt(title: str, content: str) -> str: + """슬라이드 내용을 기반으로 FLUX 이미지 프롬프트 생성""" + + # LLM을 사용하여 프롬프트 생성 + system_prompt = """You are an expert at creating visual prompts for AI image generation. +Create a concise, visually descriptive prompt in English based on the slide content. +The prompt should describe a professional, modern illustration that represents the key concepts. +Keep it under 100 words and focus on visual elements, style, and mood. +Output ONLY the prompt without any explanation.""" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Create an image prompt for:\nTitle: {title}\nContent: {content[:500]}"} + ] + + headers = { + "Authorization": f"Bearer {FRIENDLI_TOKEN}", + "Content-Type": "application/json" + } + + payload = { + "model": FRIENDLI_MODEL_ID, + "messages": messages, + "max_tokens": 200, + "temperature": 0.8, + "stream": False + } + + try: + response = requests.post(FRIENDLI_API_URL, headers=headers, json=payload, timeout=30) + if response.status_code == 200: + response_data = response.json() + if 'choices' in response_data and len(response_data['choices']) > 0: + prompt = response_data['choices'][0]['message']['content'].strip() + return f"Professional business presentation slide illustration: {prompt}, modern clean style, corporate colors, white background" + except Exception as e: + logger.error(f"Error generating FLUX prompt: {e}") + + # Fallback prompt + return f"Professional business presentation illustration about {title}, modern minimalist style, clean design, corporate colors" + +def generate_flux_image_via_api(prompt: str) -> Optional[str]: + """FLUX API를 통해 이미지 생성""" + if not FLUX_API_ENABLED or not flux_api_client: + return None + + try: + logger.info(f"Generating FLUX image with prompt: {prompt[:100]}...") + + result = flux_api_client.predict( + prompt=prompt, + width=768, + height=768, + guidance=3.5, + inference_steps=8, + seed=random.randint(1, 1000000), + do_img2img=False, + init_image=None, + image2image_strength=0.8, + resize_img=True, + api_name="/generate_image" + ) + + if isinstance(result, tuple) and len(result) > 0: + image_path = result[0] + if image_path and os.path.exists(image_path): + # PNG로 변환 + with Image.open(image_path) as img: + png_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + img.save(png_tmp.name, format="PNG") + logger.info(f"FLUX image generated and saved to {png_tmp.name}") + return png_tmp.name + + return None + + except Exception as e: + logger.error(f"Failed to generate FLUX image: {e}") + return None + +############################################################################## +# Icon and Shape Mappings +############################################################################## +SHAPE_ICONS = { + "목표": MSO_SHAPE.STAR_5_POINT, + "프로세스": MSO_SHAPE.BLOCK_ARC, + "성장": MSO_SHAPE.UP_ARROW, + "아이디어": MSO_SHAPE.LIGHTNING_BOLT, + "체크": MSO_SHAPE.RECTANGLE, + "주의": MSO_SHAPE.DIAMOND, + "질문": MSO_SHAPE.OVAL, + "분석": MSO_SHAPE.PENTAGON, + "시간": MSO_SHAPE.DONUT, + "팀": MSO_SHAPE.HEXAGON, +} + +############################################################################## +# File Processing Constants +############################################################################## +MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB +MAX_CONTENT_CHARS = 6000 + +############################################################################## +# Improved Keyword Extraction +############################################################################## +def extract_keywords(text: str, top_k: int = 5) -> str: + """ + Extract keywords: supports English and Korean + """ + stop_words = {'은', '는', '이', '가', '을', '를', '의', '에', '에서', + 'the', 'is', 'at', 'on', 'in', 'a', 'an', 'and', 'or', 'but'} + + text = re.sub(r"[^a-zA-Z0-9가-힣\s]", "", text) + tokens = text.split() + + key_tokens = [ + token for token in tokens + if token.lower() not in stop_words and len(token) > 1 + ][:top_k] + + return " ".join(key_tokens) + +############################################################################## +# File Size Validation +############################################################################## +def validate_file_size(file_path: str) -> bool: + """Check if file size is within limits""" + try: + file_size = os.path.getsize(file_path) + return file_size <= MAX_FILE_SIZE + except: + return False + +############################################################################## +# Web Search Function +############################################################################## +def do_web_search(query: str, use_korean: bool = False) -> str: + """ + Search web and return top 20 organic results + """ + if not SERPHOUSE_API_KEY: + return "Web search unavailable. API key not configured." + + try: + url = "https://api.serphouse.com/serp/live" + + params = { + "q": query, + "domain": "google.com", + "serp_type": "web", + "device": "desktop", + "lang": "ko" if use_korean else "en", + "num": "20" + } + + headers = { + "Authorization": f"Bearer {SERPHOUSE_API_KEY}" + } + + logger.info(f"Calling SerpHouse API... Query: {query}") + + response = requests.get(url, headers=headers, params=params, timeout=30) + response.raise_for_status() + + data = response.json() + + # Parse results + results = data.get("results", {}) + organic = None + + if isinstance(results, dict) and "organic" in results: + organic = results["organic"] + elif isinstance(results, dict) and "results" in results: + if isinstance(results["results"], dict) and "organic" in results["results"]: + organic = results["results"]["organic"] + elif "organic" in data: + organic = data["organic"] + + if not organic: + return "No search results found or unexpected API response structure." + + max_results = min(20, len(organic)) + limited_organic = organic[:max_results] + + summary_lines = [] + for idx, item in enumerate(limited_organic, start=1): + title = item.get("title", "No title") + link = item.get("link", "#") + snippet = item.get("snippet", "No description") + displayed_link = item.get("displayed_link", link) + + summary_lines.append( + f"### Result {idx}: {title}\n\n" + f"{snippet}\n\n" + f"**Source**: [{displayed_link}]({link})\n\n" + f"---\n" + ) + + instructions = """ +# Web Search Results +Below are the search results. Use this information when answering questions: +1. Reference the title, content, and source links +2. Explicitly cite sources in your answer (e.g., "According to source X...") +3. Include actual source links in your response +4. Synthesize information from multiple sources +""" + + search_results = instructions + "\n".join(summary_lines) + return search_results + + except requests.exceptions.Timeout: + logger.error("Web search timeout") + return "Web search timed out. Please try again." + except requests.exceptions.RequestException as e: + logger.error(f"Web search network error: {e}") + return "Network error during web search." + except Exception as e: + logger.error(f"Web search failed: {e}") + return f"Web search failed: {str(e)}" + +############################################################################## +# File Analysis Functions +############################################################################## +def analyze_csv_file(path: str) -> str: + """Analyze CSV file with size validation and encoding handling""" + if not validate_file_size(path): + return f"⚠️ Error: File size exceeds {MAX_FILE_SIZE/1024/1024:.1f}MB limit." + + try: + encodings = ['utf-8', 'cp949', 'euc-kr', 'latin-1'] + df = None + + for encoding in encodings: + try: + df = pd.read_csv(path, encoding=encoding, nrows=50) + break + except UnicodeDecodeError: + continue + + if df is None: + return f"Failed to read CSV: Unsupported encoding" + + total_rows = len(pd.read_csv(path, encoding=encoding, usecols=[0])) + + if df.shape[1] > 10: + df = df.iloc[:, :10] + + summary = f"**Data size**: {total_rows} rows x {df.shape[1]} columns\n" + summary += f"**Showing**: Top {min(50, total_rows)} rows\n" + summary += f"**Columns**: {', '.join(df.columns)}\n\n" + + # Extract data for charts + chart_data = { + "columns": list(df.columns), + "sample_data": df.head(10).to_dict('records') + } + + df_str = df.to_string() + if len(df_str) > MAX_CONTENT_CHARS: + df_str = df_str[:MAX_CONTENT_CHARS] + "\n...(truncated)..." + + return f"**[CSV File: {os.path.basename(path)}]**\n\n{summary}{df_str}\n\nCHART_DATA:{json.dumps(chart_data)}" + except Exception as e: + logger.error(f"CSV read error: {e}") + return f"Failed to read CSV file ({os.path.basename(path)}): {str(e)}" + +def analyze_txt_file(path: str) -> str: + """Analyze text file with automatic encoding detection""" + if not validate_file_size(path): + return f"⚠️ Error: File size exceeds {MAX_FILE_SIZE/1024/1024:.1f}MB limit." + + encodings = ['utf-8', 'cp949', 'euc-kr', 'latin-1', 'utf-16'] + + for encoding in encodings: + try: + with open(path, "r", encoding=encoding) as f: + text = f.read() + + file_size = os.path.getsize(path) + size_info = f"**File size**: {file_size/1024:.1f}KB\n\n" + + if len(text) > MAX_CONTENT_CHARS: + text = text[:MAX_CONTENT_CHARS] + "\n...(truncated)..." + + return f"**[TXT File: {os.path.basename(path)}]**\n\n{size_info}{text}" + except UnicodeDecodeError: + continue + + return f"Failed to read text file ({os.path.basename(path)}): Unsupported encoding" + +def pdf_to_markdown(pdf_path: str) -> str: + """Convert PDF to markdown with improved error handling""" + if not validate_file_size(pdf_path): + return f"⚠️ Error: File size exceeds {MAX_FILE_SIZE/1024/1024:.1f}MB limit." + + text_chunks = [] + try: + with open(pdf_path, "rb") as f: + reader = PyPDF2.PdfReader(f) + total_pages = len(reader.pages) + max_pages = min(5, total_pages) + + text_chunks.append(f"**Total pages**: {total_pages}") + text_chunks.append(f"**Showing**: First {max_pages} pages\n") + + for page_num in range(max_pages): + try: + page = reader.pages[page_num] + page_text = page.extract_text() or "" + page_text = page_text.strip() + + if page_text: + if len(page_text) > MAX_CONTENT_CHARS // max_pages: + page_text = page_text[:MAX_CONTENT_CHARS // max_pages] + "...(truncated)" + text_chunks.append(f"## Page {page_num+1}\n\n{page_text}\n") + except Exception as e: + text_chunks.append(f"## Page {page_num+1}\n\nFailed to read page: {str(e)}\n") + + if total_pages > max_pages: + text_chunks.append(f"\n...({max_pages}/{total_pages} pages shown)...") + except Exception as e: + logger.error(f"PDF read error: {e}") + return f"Failed to read PDF file ({os.path.basename(pdf_path)}): {str(e)}" + + full_text = "\n".join(text_chunks) + if len(full_text) > MAX_CONTENT_CHARS: + full_text = full_text[:MAX_CONTENT_CHARS] + "\n...(truncated)..." + + return f"**[PDF File: {os.path.basename(pdf_path)}]**\n\n{full_text}" + +############################################################################## +# AI Image Generation Functions using Multiple APIs +############################################################################## +def generate_diverse_prompt(title: str, content: str, slide_index: int) -> Tuple[str, str]: + """슬라이드별로 다양한 프롬프트 생성 - 3D와 포토리얼리스틱 버전""" + + # 주요 키워드 추출 + keywords = extract_keywords(f"{title} {content}", top_k=5).split() + + # 슬라이드 인덱스에 따라 다양한 스타일 적용 + styles_3d = [ + "isometric 3D illustration", + "low poly 3D art", + "3D cartoon style", + "3D glass morphism", + "3D neon glow effect", + "3D paper cut art", + "3D clay render", + "3D geometric abstract" + ] + + styles_photo = [ + "professional photography", + "cinematic shot", + "minimalist photography", + "aerial view photograph", + "macro photography", + "dramatic lighting photo", + "architectural photography", + "lifestyle photography" + ] + + # 내용 기반 시각 메타포 선택 + visual_metaphors = [] + content_lower = (title + " " + content).lower() + + if any(word in content_lower for word in ['성장', 'growth', '증가', 'increase']): + visual_metaphors = ["ascending stairs", "growing tree", "rocket launch", "mountain peak", "rising graph"] + elif any(word in content_lower for word in ['혁신', 'innovation', '창의', 'creative']): + visual_metaphors = ["lightbulb moment", "puzzle pieces connecting", "spark of genius", "breaking boundaries", "colorful explosion"] + elif any(word in content_lower for word in ['협업', 'collaboration', '팀', 'team']): + visual_metaphors = ["hands joining together", "connected network", "team huddle", "bridge building", "interlocking gears"] + elif any(word in content_lower for word in ['데이터', 'data', '분석', 'analysis']): + visual_metaphors = ["data visualization", "digital dashboard", "flowing data streams", "analytical charts", "information network"] + elif any(word in content_lower for word in ['미래', 'future', '전망', 'vision']): + visual_metaphors = ["horizon view", "crystal ball", "futuristic cityscape", "pathway to tomorrow", "digital transformation"] + elif any(word in content_lower for word in ['프로세스', 'process', '단계', 'step']): + visual_metaphors = ["flowing river", "assembly line", "domino effect", "clockwork mechanism", "journey path"] + elif any(word in content_lower for word in ['목표', 'goal', '성공', 'success']): + visual_metaphors = ["target with arrow", "trophy on pedestal", "finish line", "mountain summit", "golden key"] + else: + visual_metaphors = ["abstract shapes", "dynamic composition", "symbolic representation", "conceptual art", "modern design"] + + # 스타일과 메타포 선택 + style_3d = styles_3d[slide_index % len(styles_3d)] + style_photo = styles_photo[slide_index % len(styles_photo)] + metaphor = random.choice(visual_metaphors) + + # 색상 팔레트 다양화 + color_palettes = [ + "vibrant blue and orange", + "elegant purple and gold", + "fresh green and white", + "bold red and black", + "soft pastel tones", + "monochromatic blue", + "warm sunset colors", + "cool ocean palette" + ] + colors = color_palettes[slide_index % len(color_palettes)] + + # 3D 스타일 프롬프트 (한글) + prompt_3d = f"wbgmsst, {style_3d}, {metaphor} representing {' '.join(keywords[:3])}, {colors}, professional presentation slide, high quality, white background" + + # 포토리얼리스틱 프롬프트 (영어) + prompt_photo = f"{style_photo} of {metaphor} symbolizing {' '.join(keywords[:3])}, {colors} color scheme, professional business context, clean composition, high resolution" + + return prompt_3d, prompt_photo + +def generate_cover_image_prompts(topic: str, slides_data: list) -> Tuple[str, str]: + """표지용 3D와 포토리얼리스틱 프롬프트 생성""" + + keywords = extract_keywords(topic, top_k=3).split() + + # 주제별 특화된 시각 요소 + topic_lower = topic.lower() + if any(word in topic_lower for word in ['기술', 'tech', 'ai', '인공지능']): + visual_3d = "futuristic 3D holographic interface" + visual_photo = "modern technology workspace with holographic displays" + elif any(word in topic_lower for word in ['비즈니스', 'business', '경영']): + visual_3d = "3D corporate building with glass architecture" + visual_photo = "professional business meeting in modern office" + elif any(word in topic_lower for word in ['교육', 'education', '학습']): + visual_3d = "3D books transforming into knowledge symbols" + visual_photo = "inspiring educational environment with digital elements" + elif any(word in topic_lower for word in ['환경', 'environment', '자연']): + visual_3d = "3D earth with renewable energy icons" + visual_photo = "pristine nature landscape with sustainable elements" + else: + visual_3d = "abstract 3D geometric composition" + visual_photo = "professional abstract photography" + + prompt_3d = f"wbgmsst, {visual_3d}, {' '.join(keywords)} theme, premium 3D render, elegant composition, gradient background" + prompt_photo = f"{visual_photo} featuring {' '.join(keywords)}, cinematic lighting, professional presentation cover, high-end photography" + + return prompt_3d, prompt_photo + +def generate_conclusion_image_prompts(title: str, content: str) -> Tuple[str, str]: + """결론 슬라이드용 특별한 프롬프트 생성""" + + keywords = extract_keywords(f"{title} {content}", top_k=4).split() + + # 결론 스타일 비주얼 + prompt_3d = f"wbgmsst, 3D trophy or achievement symbol, {' '.join(keywords[:2])} success visualization, golden lighting, celebration mood, premium quality" + prompt_photo = f"inspirational sunrise or horizon view symbolizing {' '.join(keywords[:2])}, bright future ahead, professional photography, uplifting atmosphere" + + return prompt_3d, prompt_photo + +def generate_ai_image_via_3d_api(prompt: str) -> Optional[str]: + """3D 스타일 API를 통해 이미지 생성""" + if not AI_IMAGE_ENABLED or not ai_image_client: + return None + + try: + logger.info(f"Generating 3D style image with prompt: {prompt[:100]}...") + + result = ai_image_client.predict( + height=1024.0, + width=1024.0, + steps=8.0, + scales=3.5, + prompt=prompt, + seed=float(random.randint(0, 1000000)), + api_name="/process_and_save_image" + ) + + # 결과 처리 + image_path = None + if isinstance(result, dict): + image_path = result.get("path") + elif isinstance(result, str): + image_path = result + + if image_path and os.path.exists(image_path): + # PNG로 변환 + with Image.open(image_path) as img: + png_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + img.save(png_tmp.name, format="PNG") + logger.info(f"3D image generated and saved to {png_tmp.name}") + return png_tmp.name + + return None + + except Exception as e: + logger.error(f"Failed to generate 3D image: {e}") + return None + +def generate_flux_image_via_api(prompt: str) -> Optional[str]: + """FLUX API를 통해 포토리얼리스틱 이미지 생성""" + if not FLUX_API_ENABLED or not flux_api_client: + return None + + try: + logger.info(f"Generating FLUX photorealistic image with prompt: {prompt[:100]}...") + + result = flux_api_client.predict( + prompt=prompt, + width=768, + height=768, + guidance=3.5, + inference_steps=8, + seed=random.randint(1, 1000000), + do_img2img=False, + init_image=None, + image2image_strength=0.8, + resize_img=True, + api_name="/generate_image" + ) + + if isinstance(result, tuple) and len(result) > 0: + image_path = result[0] + if image_path and os.path.exists(image_path): + with Image.open(image_path) as img: + png_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png") + img.save(png_tmp.name, format="PNG") + logger.info(f"FLUX image generated and saved to {png_tmp.name}") + return png_tmp.name + + return None + + except Exception as e: + logger.error(f"Failed to generate FLUX image: {e}") + return None + +def generate_images_parallel(prompt_3d: str, prompt_photo: str) -> Tuple[Optional[str], Optional[str]]: + """두 API를 병렬로 호출하여 이미지 생성""" + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + # 두 API를 동시에 호출 + future_3d = executor.submit(generate_ai_image_via_3d_api, prompt_3d) + future_photo = executor.submit(generate_flux_image_via_api, prompt_photo) + + # 결과 대기 + image_3d = future_3d.result() + image_photo = future_photo.result() + + return image_3d, image_photo + +############################################################################## +# PPT Generation Functions - FIXED VERSION +############################################################################## +def parse_llm_ppt_response(response: str, layout_style: str = "consistent") -> list: + """Parse LLM response to extract slide content - COMPLETELY FIXED VERSION""" + slides = [] + + # Debug: 전체 응답 확인 + logger.info(f"Parsing LLM response, total length: {len(response)}") + logger.debug(f"First 500 chars: {response[:500]}") + + # Try JSON parsing first + try: + json_match = re.search(r'\[[\s\S]*\]', response) + if json_match: + slides_data = json.loads(json_match.group()) + return slides_data + except: + pass + + # Split by slide markers and process each section + # 슬라이드를 구분하는 더 강력한 정규식 + slide_pattern = r'(?:^|\n)(?:슬라이드|Slide)\s*\d+|(?:^|\n)\d+[\.)](?:\s|$)' + + # 슬라이드 섹션으로 분할 + sections = re.split(slide_pattern, response, flags=re.MULTILINE | re.IGNORECASE) + + # 첫 번째 빈 섹션 제거 + if sections and not sections[0].strip(): + sections = sections[1:] + + logger.info(f"Found {len(sections)} potential slide sections") + + for idx, section in enumerate(sections): + if not section.strip(): + continue + + logger.debug(f"Processing section {idx}: {section[:100]}...") + + slide = { + 'title': '', + 'content': '', + 'notes': '', + 'layout': 'title_content', + 'chart_data': None + } + + # 섹션 내에서 제목, 내용, 노트 추출 + lines = section.strip().split('\n') + current_part = None + title_lines = [] + content_lines = [] + notes_lines = [] + + for line in lines: + line = line.strip() + if not line: + continue + + # 제목 섹션 감지 + if line.startswith('제목:') or line.startswith('Title:'): + current_part = 'title' + title_text = line.split(':', 1)[1].strip() if ':' in line else '' + if title_text: + title_lines.append(title_text) + # 내용 섹션 감지 + elif line.startswith('내용:') or line.startswith('Content:'): + current_part = 'content' + content_text = line.split(':', 1)[1].strip() if ':' in line else '' + if content_text: + content_lines.append(content_text) + # 노트 섹션 감지 + elif line.startswith('노트:') or line.startswith('Notes:') or line.startswith('발표자 노트:'): + current_part = 'notes' + notes_text = line.split(':', 1)[1].strip() if ':' in line else '' + if notes_text: + notes_lines.append(notes_text) + # 현재 섹션에 따라 내용 추가 + else: + if current_part == 'title' and not title_lines: + title_lines.append(line) + elif current_part == 'content': + content_lines.append(line) + elif current_part == 'notes': + notes_lines.append(line) + elif not current_part and not title_lines: + # 첫 번째 줄을 제목으로 + title_lines.append(line) + current_part = 'content' # 이후 줄들은 content로 + elif not current_part: + content_lines.append(line) + + # 슬라이드 데이터 설정 + slide['title'] = ' '.join(title_lines).strip() + slide['content'] = '\n'.join(content_lines).strip() + slide['notes'] = ' '.join(notes_lines).strip() + + # 제목 정리 + slide['title'] = re.sub(r'^(슬라이드|Slide)\s*\d+\s*[::\-]?\s*', '', slide['title'], flags=re.IGNORECASE) + slide['title'] = re.sub(r'^(제목|Title)\s*[::]\s*', '', slide['title'], flags=re.IGNORECASE) + + # 내용이 있는 경우에만 추가 + if slide['title'] or slide['content']: + logger.info(f"Slide {len(slides)+1}: Title='{slide['title'][:30]}...', Content length={len(slide['content'])}") + slides.append(slide) + + # 만약 위 방법으로 파싱이 안 되었다면, 더 간단한 방법 시도 + if not slides: + logger.warning("Primary parsing failed, trying fallback method...") + + # 더블 뉴라인으로 구분 + sections = response.split('\n\n') + for section in sections: + lines = section.strip().split('\n') + if len(lines) >= 2: # 최소 제목과 내용이 있어야 함 + slide = { + 'title': lines[0].strip(), + 'content': '\n'.join(lines[1:]).strip(), + 'notes': '', + 'layout': 'title_content', + 'chart_data': None + } + + # 제목 정리 + slide['title'] = re.sub(r'^(슬라이드|Slide)\s*\d+\s*[::\-]?\s*', '', slide['title'], flags=re.IGNORECASE) + + if slide['title'] and slide['content']: + slides.append(slide) + + logger.info(f"Total slides parsed: {len(slides)}") + return slides + +def force_font_size(text_frame, font_size_pt: int, theme: Dict): + """Force font size for all paragraphs and runs in a text frame""" + if not text_frame: + return + + try: + # Ensure paragraphs exist + if not hasattr(text_frame, 'paragraphs'): + return + + for paragraph in text_frame.paragraphs: + try: + # Set paragraph level font + if hasattr(paragraph, 'font'): + paragraph.font.size = Pt(font_size_pt) + paragraph.font.name = theme['fonts']['body'] + paragraph.font.color.rgb = theme['colors']['text'] + + # Set run level font (most important for actual rendering) + if hasattr(paragraph, 'runs'): + for run in paragraph.runs: + run.font.size = Pt(font_size_pt) + run.font.name = theme['fonts']['body'] + run.font.color.rgb = theme['colors']['text'] + + # If paragraph has no runs but has text, create a run + if paragraph.text and (not hasattr(paragraph, 'runs') or len(paragraph.runs) == 0): + # Force creation of runs by modifying text + temp_text = paragraph.text + paragraph.text = temp_text # This creates runs + if hasattr(paragraph, 'runs'): + for run in paragraph.runs: + run.font.size = Pt(font_size_pt) + run.font.name = theme['fonts']['body'] + run.font.color.rgb = theme['colors']['text'] + except Exception as e: + logger.warning(f"Error setting font for paragraph: {e}") + continue + except Exception as e: + logger.warning(f"Error in force_font_size: {e}") + +def apply_theme_to_slide(slide, theme: Dict, layout_type: str = 'title_content'): + """Apply design theme to a slide with consistent styling""" + # Add colored background shape for all slides + bg_shape = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, 0, 0, Inches(10), Inches(5.625) + ) + bg_shape.fill.solid() + + # Use lighter background for content slides + if layout_type in ['title_content', 'two_content', 'comparison']: + # Light background with subtle gradient effect + bg_shape.fill.fore_color.rgb = theme['colors']['background'] + + # Add accent strip at top + accent_strip = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, 0, 0, Inches(10), Inches(0.5) + ) + accent_strip.fill.solid() + accent_strip.fill.fore_color.rgb = theme['colors']['primary'] + accent_strip.line.fill.background() + + # Add bottom accent + bottom_strip = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, 0, Inches(5.125), Inches(10), Inches(0.5) + ) + bottom_strip.fill.solid() + bottom_strip.fill.fore_color.rgb = theme['colors']['secondary'] + bottom_strip.fill.transparency = 0.7 + bottom_strip.line.fill.background() + + else: + # Section headers get primary color background + bg_shape.fill.fore_color.rgb = theme['colors']['primary'] + + bg_shape.line.fill.background() + + # Move background shapes to back + slide.shapes._spTree.remove(bg_shape._element) + slide.shapes._spTree.insert(2, bg_shape._element) + + # Apply title formatting if exists + if slide.shapes.title: + try: + title = slide.shapes.title + if title.text_frame and title.text_frame.paragraphs: + for paragraph in title.text_frame.paragraphs: + paragraph.font.name = theme['fonts']['title'] + paragraph.font.bold = True + + # UPDATED: Increased font sizes for better readability + if layout_type == 'section_header': + paragraph.font.size = Pt(28) # Increased from 20 + paragraph.font.color.rgb = RGBColor(255, 255, 255) + paragraph.alignment = PP_ALIGN.CENTER + else: + paragraph.font.size = Pt(24) # Increased from 18 + paragraph.font.color.rgb = theme['colors']['primary'] + paragraph.alignment = PP_ALIGN.LEFT + except Exception as e: + logger.warning(f"Title formatting failed: {e}") + + # Apply content formatting with improved readability + # NOTE: Do NOT add emojis here - they will be added in create_advanced_ppt_from_content + for shape in slide.shapes: + if shape.has_text_frame and shape != slide.shapes.title: + try: + text_frame = shape.text_frame + + # Set text frame margins for better spacing + text_frame.margin_left = Inches(0.25) + text_frame.margin_right = Inches(0.25) + text_frame.margin_top = Inches(0.1) + text_frame.margin_bottom = Inches(0.1) + + # Only apply font formatting, no content modification + if text_frame.text.strip(): + # Use force_font_size helper to ensure font is applied + force_font_size(text_frame, 16, theme) # Increased from 12 + + for paragraph in text_frame.paragraphs: + # Add line spacing for better readability + paragraph.space_after = Pt(4) # Increased from 3 + paragraph.line_spacing = 1.2 # Increased from 1.1 + + except Exception as e: + logger.warning(f"Content formatting failed: {e}") + +def add_gradient_background(slide, color1: RGBColor, color2: RGBColor): + """Add gradient-like background to slide using shapes""" + # Note: python-pptx doesn't directly support gradients in backgrounds, + # so we'll create a gradient effect using overlapping shapes + left = top = 0 + width = Inches(10) + height = Inches(5.625) + + # Add base color rectangle + shape1 = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, left, top, width, height + ) + shape1.fill.solid() + shape1.fill.fore_color.rgb = color1 + shape1.line.fill.background() + + # Add semi-transparent overlay for gradient effect + shape2 = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, left, top, width, Inches(2.8) + ) + shape2.fill.solid() + shape2.fill.fore_color.rgb = color2 + shape2.fill.transparency = 0.5 + shape2.line.fill.background() + + # Move shapes to back + slide.shapes._spTree.remove(shape1._element) + slide.shapes._spTree.remove(shape2._element) + slide.shapes._spTree.insert(2, shape1._element) + slide.shapes._spTree.insert(3, shape2._element) + +def add_decorative_shapes(slide, theme: Dict): + """Add decorative shapes to enhance visual appeal""" + try: + # Add corner accent circle + shape1 = slide.shapes.add_shape( + MSO_SHAPE.OVAL, + Inches(9.3), Inches(4.8), + Inches(0.7), Inches(0.7) + ) + shape1.fill.solid() + shape1.fill.fore_color.rgb = theme['colors']['accent'] + shape1.fill.transparency = 0.3 + shape1.line.fill.background() + + # Add smaller accent + shape2 = slide.shapes.add_shape( + MSO_SHAPE.OVAL, + Inches(0.1), Inches(0.1), + Inches(0.4), Inches(0.4) + ) + shape2.fill.solid() + shape2.fill.fore_color.rgb = theme['colors']['secondary'] + shape2.fill.transparency = 0.5 + shape2.line.fill.background() + + except Exception as e: + logger.warning(f"Failed to add decorative shapes: {e}") + +def create_chart_slide(slide, chart_data: Dict, theme: Dict): + """Create a chart on the slide based on data""" + try: + # Add chart + x, y, cx, cy = Inches(1), Inches(2), Inches(8), Inches(4.5) + + # Prepare chart data + chart_data_obj = CategoryChartData() + + # Simple bar chart example + if 'columns' in chart_data and 'sample_data' in chart_data: + # Use first numeric column for chart + numeric_cols = [] + for col in chart_data['columns']: + try: + # Check if column has numeric data + float(chart_data['sample_data'][0].get(col, 0)) + numeric_cols.append(col) + except: + pass + + if numeric_cols: + categories = [str(row.get(chart_data['columns'][0], '')) + for row in chart_data['sample_data'][:5]] + chart_data_obj.categories = categories + + for col in numeric_cols[:3]: # Max 3 series + values = [float(row.get(col, 0)) + for row in chart_data['sample_data'][:5]] + chart_data_obj.add_series(col, values) + + chart = slide.shapes.add_chart( + XL_CHART_TYPE.COLUMN_CLUSTERED, x, y, cx, cy, chart_data_obj + ).chart + + # Style the chart + chart.has_legend = True + chart.legend.position = XL_LEGEND_POSITION.BOTTOM + except Exception as e: + logger.warning(f"Chart creation failed: {e}") + # If chart fails, add a text placeholder instead + textbox = slide.shapes.add_textbox(x, y, cx, cy) + text_frame = textbox.text_frame + text_frame.text = "데이터 차트 (차트 생성 실패)" + text_frame.paragraphs[0].font.size = Pt(16) # Increased font size + text_frame.paragraphs[0].font.color.rgb = theme['colors']['secondary'] + +def create_advanced_ppt_from_content( + slides_data: list, + topic: str, + theme_name: str, + include_charts: bool = False, + include_ai_image: bool = False, + include_diagrams: bool = False, + include_flux_images: bool = False +) -> str: + """Create advanced PPT file with consistent visual design and AI-generated visuals""" + if not PPTX_AVAILABLE: + raise ImportError("python-pptx library is required") + + prs = Presentation() + theme = DESIGN_THEMES.get(theme_name, DESIGN_THEMES['professional']) + + # Set slide size (16:9) + prs.slide_width = Inches(10) + prs.slide_height = Inches(5.625) + + # ───────────────────────────────────────────────────────── + # 1) 제목 슬라이드(표지) 생성 - 수정된 레이아웃 + # ───────────────────────────────────────────────────────── + title_slide_layout = prs.slide_layouts[0] + slide = prs.slides.add_slide(title_slide_layout) + + # 배경 그라디언트 + add_gradient_background(slide, theme['colors']['primary'], theme['colors']['secondary']) + + # 제목과 부제목을 중앙 상단에 배치 + title_shape = slide.shapes.title + subtitle_shape = slide.placeholders[1] if len(slide.placeholders) > 1 else None + + if title_shape: + title_shape.left = Inches(0.5) + title_shape.width = prs.slide_width - Inches(1) + title_shape.top = Inches(1.0) # 상단에 배치 + title_shape.height = Inches(1.2) + + tf = title_shape.text_frame + tf.clear() + tf.text = topic + p = tf.paragraphs[0] + p.font.name = theme['fonts']['title'] + p.font.size = Pt(36) + p.font.bold = True + p.font.color.rgb = RGBColor(255, 255, 255) + p.alignment = PP_ALIGN.CENTER + + if subtitle_shape: + subtitle_shape.left = Inches(0.5) + subtitle_shape.width = prs.slide_width - Inches(1) + subtitle_shape.top = Inches(2.2) # 제목 아래에 배치 + subtitle_shape.height = Inches(0.9) + + tf2 = subtitle_shape.text_frame + tf2.clear() + tf2.text = f"자동 생성된 프레젠테이션 • 총 {len(slides_data)}장" + p2 = tf2.paragraphs[0] + p2.font.name = theme['fonts']['subtitle'] + p2.font.size = Pt(20) + p2.font.color.rgb = RGBColor(255, 255, 255) + p2.alignment = PP_ALIGN.CENTER + + # AI 이미지를 우측 하단에 배치 (두 API 병렬 사용) + if include_ai_image and (AI_IMAGE_ENABLED or FLUX_API_ENABLED): + logger.info("Generating AI cover images via parallel APIs...") + + # 두 가지 프롬프트 생성 + prompt_3d, prompt_photo = generate_cover_image_prompts(topic, slides_data) + + # 병렬로 이미지 생성 + image_3d, image_photo = generate_images_parallel(prompt_3d, prompt_photo) + + # 성공한 이미지 중 하나 선택 (3D 우선) + ai_image_path = image_3d or image_photo + + if ai_image_path and os.path.exists(ai_image_path): + try: + img = Image.open(ai_image_path) + img_width, img_height = img.size + + # 이미지를 우측 하단에 배치 + max_width = Inches(3.5) + max_height = Inches(2.5) + + ratio = img_height / img_width + img_w = max_width + img_h = max_width * ratio + + if img_h > max_height: + img_h = max_height + img_w = max_height / ratio + + # 우측 하단 배치 + left = prs.slide_width - img_w - Inches(0.5) + top = prs.slide_height - img_h - Inches(0.8) + + pic = slide.shapes.add_picture(ai_image_path, left, top, width=img_w, height=img_h) + pic.shadow.inherit = False + pic.shadow.visible = True + pic.shadow.blur_radius = Pt(15) + pic.shadow.distance = Pt(8) + pic.shadow.angle = 45 + + # 이미지 위에 작은 캡션 추가 + caption_box = slide.shapes.add_textbox( + left, top - Inches(0.3), + img_w, Inches(0.3) + ) + caption_tf = caption_box.text_frame + caption_tf.text = "AI Generated" + caption_p = caption_tf.paragraphs[0] + caption_p.font.size = Pt(10) + caption_p.font.color.rgb = RGBColor(255, 255, 255) + caption_p.alignment = PP_ALIGN.CENTER + + # 임시 파일 정리 + for temp_path in [image_3d, image_photo]: + if temp_path and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except: + pass + + except Exception as e: + logger.error(f"Failed to add cover image: {e}") + + # 장식 요소 + add_decorative_shapes(slide, theme) + + # Add content slides with consistent design + for i, slide_data in enumerate(slides_data): + layout_type = slide_data.get('layout', 'title_content') + + # Log slide creation + logger.info(f"Creating slide {i+1}: {slide_data.get('title', 'No title')}") + logger.debug(f"Content length: {len(slide_data.get('content', ''))}") + + # Choose appropriate layout + if layout_type == 'section_header' and len(prs.slide_layouts) > 2: + slide_layout = prs.slide_layouts[2] + elif layout_type == 'two_content' and len(prs.slide_layouts) > 3: + slide_layout = prs.slide_layouts[3] + elif layout_type == 'comparison' and len(prs.slide_layouts) > 4: + slide_layout = prs.slide_layouts[4] + else: + slide_layout = prs.slide_layouts[1] if len(prs.slide_layouts) > 1 else prs.slide_layouts[0] + + slide = prs.slides.add_slide(slide_layout) + + # Apply theme to EVERY slide for consistency + apply_theme_to_slide(slide, theme, layout_type) + + # Set title + if slide.shapes.title: + slide.shapes.title.text = slide_data.get('title', '제목 없음') + # IMMEDIATELY set title font size after setting text + try: + title_text_frame = slide.shapes.title.text_frame + if title_text_frame and title_text_frame.paragraphs: + for paragraph in title_text_frame.paragraphs: + if layout_type == 'section_header': + paragraph.font.size = Pt(28) # Increased from 20 + paragraph.font.color.rgb = RGBColor(255, 255, 255) + paragraph.alignment = PP_ALIGN.CENTER + else: + paragraph.font.size = Pt(24) # Increased from 18 + paragraph.font.color.rgb = theme['colors']['primary'] + paragraph.font.bold = True + paragraph.font.name = theme['fonts']['title'] + except Exception as e: + logger.warning(f"Title font sizing failed: {e}") + + # Detect if this slide should have a diagram or image + slide_title = slide_data.get('title', '') + slide_content = slide_data.get('content', '') + + # 결론/하이라이트 슬라이드 감지 + is_conclusion_slide = any(word in slide_title.lower() for word in + ['결론', 'conclusion', '요약', 'summary', '핵심', 'key', + '마무리', 'closing', '정리', 'takeaway', '시사점', 'implication']) + + # 다이어그램 또는 이미지 생성 여부 결정 + should_add_visual = False + visual_type = None + + # 결론 슬라이드는 항상 이미지 추가 + if is_conclusion_slide and include_flux_images: + should_add_visual = True + visual_type = ('conclusion_images', None) + elif include_diagrams: + diagram_type = detect_diagram_type(slide_title, slide_content) + if diagram_type: + should_add_visual = True + visual_type = ('diagram', diagram_type) + elif not should_add_visual and include_flux_images and i % 2 == 0: # 매 2번째 슬라이드에 이미지 + should_add_visual = True + visual_type = ('diverse_images', None) + + # 시각적 요소가 있는 경우 좌-우 레이아웃 적용 + if should_add_visual and layout_type not in ['section_header']: + # 좌측에 텍스트 배치 + left_box = slide.shapes.add_textbox( + Inches(0.5), Inches(1.5), Inches(4.5), Inches(3.5) + ) + left_tf = left_box.text_frame + left_tf.clear() + left_tf.text = slide_content + left_tf.word_wrap = True + force_font_size(left_tf, 14, theme) + + # Apply emoji bullets + for paragraph in left_tf.paragraphs: + text = paragraph.text.strip() + if text and text.startswith(('-', '•', '●')) and not has_emoji(text): + clean_text = text.lstrip('-•● ') + emoji = get_emoji_for_content(clean_text) + paragraph.text = f"{emoji} {clean_text}" + force_font_size(left_tf, 14, theme) + + # 우측에 시각적 요소 추가 + visual_added = False + + if visual_type[0] == 'diagram': + # 다이어그램 생성 + logger.info(f"Generating {visual_type[1]} for slide {i+1}") + diagram_json = generate_diagram_json(slide_title, slide_content, visual_type[1]) + + if diagram_json: + diagram_path = generate_diagram_via_api(diagram_json, visual_type[1]) + if diagram_path and os.path.exists(diagram_path): + try: + # 다이어그램 이미지 추가 + pic = slide.shapes.add_picture( + diagram_path, + Inches(5.2), Inches(1.5), + width=Inches(4.3), height=Inches(3.0) + ) + visual_added = True + + # 임시 파일 삭제 + os.unlink(diagram_path) + except Exception as e: + logger.error(f"Failed to add diagram: {e}") + + elif visual_type[0] == 'conclusion_images': + # 결론 슬라이드용 이미지 생성 (두 API 병렬) + logger.info(f"Generating conclusion images for slide {i+1}") + prompt_3d, prompt_photo = generate_conclusion_image_prompts(slide_title, slide_content) + image_3d, image_photo = generate_images_parallel(prompt_3d, prompt_photo) + + # 성공한 이미지 중 하나 선택 + selected_image = image_photo or image_3d # 결론은 포토리얼리스틱 우선 + + if selected_image and os.path.exists(selected_image): + try: + pic = slide.shapes.add_picture( + selected_image, + Inches(5.2), Inches(1.5), + width=Inches(4.3), height=Inches(3.0) + ) + visual_added = True + + # 이미지 캡션 추가 + caption_box = slide.shapes.add_textbox( + Inches(5.2), Inches(4.6), Inches(4.3), Inches(0.3) + ) + caption_tf = caption_box.text_frame + caption_tf.text = "Key Takeaway Visualization" + caption_p = caption_tf.paragraphs[0] + caption_p.font.size = Pt(10) + caption_p.font.color.rgb = theme['colors']['secondary'] + caption_p.alignment = PP_ALIGN.CENTER + + # 임시 파일 정리 + for temp_path in [image_3d, image_photo]: + if temp_path and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except: + pass + except Exception as e: + logger.error(f"Failed to add conclusion image: {e}") + + elif visual_type[0] == 'diverse_images': + # 다양한 슬라이드 이미지 생성 (두 API 병렬) + logger.info(f"Generating diverse images for slide {i+1}") + prompt_3d, prompt_photo = generate_diverse_prompt(slide_title, slide_content, i) + image_3d, image_photo = generate_images_parallel(prompt_3d, prompt_photo) + + # 슬라이드 인덱스에 따라 번갈아 선택 + selected_image = image_3d if i % 2 == 0 else image_photo + if not selected_image: # 실패시 다른 것 선택 + selected_image = image_photo if i % 2 == 0 else image_3d + + if selected_image and os.path.exists(selected_image): + try: + pic = slide.shapes.add_picture( + selected_image, + Inches(5.2), Inches(1.5), + width=Inches(4.3), height=Inches(3.0) + ) + visual_added = True + + # 임시 파일 정리 + for temp_path in [image_3d, image_photo]: + if temp_path and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except: + pass + except Exception as e: + logger.error(f"Failed to add slide image: {e}") + + # 시각적 요소가 추가되지 않은 경우 플레이스홀더 추가 + if not visual_added: + placeholder_box = slide.shapes.add_textbox( + Inches(5.2), Inches(2.5), Inches(4.3), Inches(1.0) + ) + placeholder_tf = placeholder_box.text_frame + placeholder_tf.text = f"{visual_type[1] if visual_type[0] == 'diagram' else 'Visual'} Placeholder" + placeholder_tf.paragraphs[0].font.size = Pt(14) + placeholder_tf.paragraphs[0].font.color.rgb = theme['colors']['secondary'] + placeholder_tf.paragraphs[0].alignment = PP_ALIGN.CENTER + + else: + # 기본 레이아웃 (시각적 요소 없음) + if layout_type == 'section_header': + # Section header content handling + content = slide_data.get('content', '') + if content: + logger.info(f"Adding content to section header slide {i+1}: {content[:50]}...") + textbox = slide.shapes.add_textbox( + Inches(1), Inches(3.5), Inches(8), Inches(1.5) + ) + tf = textbox.text_frame + tf.clear() + tf.text = content + tf.word_wrap = True + + for paragraph in tf.paragraphs: + paragraph.font.name = theme['fonts']['body'] + paragraph.font.size = Pt(16) + paragraph.font.color.rgb = RGBColor(255, 255, 255) + paragraph.alignment = PP_ALIGN.CENTER + + # Add decorative line + line = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, Inches(3), Inches(3.2), Inches(4), Pt(4) + ) + line.fill.solid() + line.fill.fore_color.rgb = RGBColor(255, 255, 255) + line.line.fill.background() + + elif layout_type == 'two_content': + content = slide_data.get('content', '') + if content: + logger.info(f"Creating two-column layout for slide {i+1}") + content_lines = content.split('\n') + mid_point = len(content_lines) // 2 + + # Left column + left_box = slide.shapes.add_textbox( + Inches(0.5), Inches(1.5), Inches(4.5), Inches(3.5) + ) + left_tf = left_box.text_frame + left_tf.clear() + left_content = '\n'.join(content_lines[:mid_point]) + if left_content: + left_tf.text = left_content + left_tf.word_wrap = True + force_font_size(left_tf, 14, theme) + + # Apply emoji bullets + for paragraph in left_tf.paragraphs: + text = paragraph.text.strip() + if text and text.startswith(('-', '•', '●')) and not has_emoji(text): + clean_text = text.lstrip('-•● ') + emoji = get_emoji_for_content(clean_text) + paragraph.text = f"{emoji} {clean_text}" + force_font_size(left_tf, 14, theme) + + # Right column + right_box = slide.shapes.add_textbox( + Inches(5), Inches(1.5), Inches(4.5), Inches(3.5) + ) + right_tf = right_box.text_frame + right_tf.clear() + right_content = '\n'.join(content_lines[mid_point:]) + if right_content: + right_tf.text = right_content + right_tf.word_wrap = True + force_font_size(right_tf, 14, theme) + + # Apply emoji bullets + for paragraph in right_tf.paragraphs: + text = paragraph.text.strip() + if text and text.startswith(('-', '•', '●')) and not has_emoji(text): + clean_text = text.lstrip('-•● ') + emoji = get_emoji_for_content(clean_text) + paragraph.text = f"{emoji} {clean_text}" + force_font_size(right_tf, 14, theme) + + else: + # Regular content + content = slide_data.get('content', '') + + logger.info(f"Slide {i+1} - Content to add: '{content[:100]}...' (length: {len(content)})") + + if include_charts and slide_data.get('chart_data'): + create_chart_slide(slide, slide_data['chart_data'], theme) + + if content and content.strip(): + textbox = slide.shapes.add_textbox( + Inches(0.5), # left + Inches(1.5), # top + Inches(9), # width + Inches(3.5) # height + ) + + tf = textbox.text_frame + tf.clear() + + tf.text = content.strip() + tf.word_wrap = True + + tf.margin_left = Inches(0.1) + tf.margin_right = Inches(0.1) + tf.margin_top = Inches(0.05) + tf.margin_bottom = Inches(0.05) + + force_font_size(tf, 16, theme) + + for p_idx, paragraph in enumerate(tf.paragraphs): + if paragraph.text.strip(): + text = paragraph.text.strip() + if text.startswith(('-', '•', '●')) and not has_emoji(text): + clean_text = text.lstrip('-•● ') + emoji = get_emoji_for_content(clean_text) + paragraph.text = f"{emoji} {clean_text}" + + if paragraph.runs: + for run in paragraph.runs: + run.font.size = Pt(16) + run.font.name = theme['fonts']['body'] + run.font.color.rgb = theme['colors']['text'] + else: + paragraph.font.size = Pt(16) + paragraph.font.name = theme['fonts']['body'] + paragraph.font.color.rgb = theme['colors']['text'] + + paragraph.space_before = Pt(6) + paragraph.space_after = Pt(6) + paragraph.line_spacing = 1.3 + + logger.info(f"Successfully added content to slide {i+1}") + else: + logger.warning(f"Slide {i+1} has no content or empty content") + + # Add slide notes if available + if slide_data.get('notes'): + try: + notes_slide = slide.notes_slide + notes_text_frame = notes_slide.notes_text_frame + notes_text_frame.text = slide_data.get('notes', '') + except Exception as e: + logger.warning(f"Failed to add slide notes: {e}") + + # Add slide number with better design + slide_number_bg = slide.shapes.add_shape( + MSO_SHAPE.ROUNDED_RECTANGLE, + Inches(8.3), Inches(5.0), Inches(1.5), Inches(0.5) + ) + slide_number_bg.fill.solid() + slide_number_bg.fill.fore_color.rgb = theme['colors']['primary'] + slide_number_bg.fill.transparency = 0.8 + slide_number_bg.line.fill.background() + + slide_number_box = slide.shapes.add_textbox( + Inches(8.3), Inches(5.05), Inches(1.5), Inches(0.4) + ) + slide_number_frame = slide_number_box.text_frame + slide_number_frame.text = f"{i + 1} / {len(slides_data)}" + slide_number_frame.paragraphs[0].font.size = Pt(10) # Increased from 8 + slide_number_frame.paragraphs[0].font.color.rgb = RGBColor(255, 255, 255) + slide_number_frame.paragraphs[0].font.bold = False + slide_number_frame.paragraphs[0].alignment = PP_ALIGN.CENTER + + # Add subtle design element on alternating slides + if i % 2 == 0: + accent_shape = slide.shapes.add_shape( + MSO_SHAPE.OVAL, + Inches(9.6), Inches(0.1), + Inches(0.2), Inches(0.2) + ) + accent_shape.fill.solid() + accent_shape.fill.fore_color.rgb = theme['colors']['accent'] + accent_shape.line.fill.background() + + # Add thank you slide with consistent design + thank_you_layout = prs.slide_layouts[5] if len(prs.slide_layouts) > 5 else prs.slide_layouts[0] + thank_you_slide = prs.slides.add_slide(thank_you_layout) + + # Apply gradient background + add_gradient_background(thank_you_slide, theme['colors']['secondary'], theme['colors']['primary']) + + if thank_you_slide.shapes.title: + thank_you_slide.shapes.title.text = "감사합니다" + try: + if thank_you_slide.shapes.title.text_frame and thank_you_slide.shapes.title.text_frame.paragraphs: + thank_you_slide.shapes.title.text_frame.paragraphs[0].font.size = Pt(36) # Increased from 28 + thank_you_slide.shapes.title.text_frame.paragraphs[0].font.bold = True + thank_you_slide.shapes.title.text_frame.paragraphs[0].font.color.rgb = RGBColor(255, 255, 255) + thank_you_slide.shapes.title.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER + except Exception as e: + logger.warning(f"Thank you slide styling failed: {e}") + + # Add contact or additional info placeholder + info_box = thank_you_slide.shapes.add_textbox( + Inches(2), Inches(3.5), Inches(6), Inches(1) + ) + info_tf = info_box.text_frame + info_tf.text = "AI로 생성된 프레젠테이션" + info_tf.paragraphs[0].font.size = Pt(18) # Increased from 14 + info_tf.paragraphs[0].font.color.rgb = RGBColor(255, 255, 255) + info_tf.paragraphs[0].alignment = PP_ALIGN.CENTER + + # Save to temporary file + with tempfile.NamedTemporaryFile(delete=False, suffix=".pptx") as tmp_file: + prs.save(tmp_file.name) + return tmp_file.name + +############################################################################## +# Streaming Response Handler for PPT Generation - IMPROVED VERSION +############################################################################## +def generate_ppt_content(topic: str, num_slides: int, additional_context: str, use_korean: bool = False, layout_style: str = "consistent") -> Iterator[str]: + """Generate PPT content using LLM with clearer format""" + + # Layout instructions based on style + layout_instructions = "" + if layout_style == "varied": + layout_instructions = """ +슬라이드 레이아웃을 다양하게 구성해주세요: +- 매 5번째 슬라이드는 '섹션 구분' 슬라이드로 만들어주세요 +- 비교나 대조 내용이 있으면 '비교' 레이아웃을 사용하세요 +- 내용이 많으면 2단 구성을 고려하세요 +""" + elif layout_style == "consistent": + layout_instructions = """ +일관된 레이아웃을 유지해주세요: +- 모든 슬라이드는 동일한 구조로 작성 +- 제목과 글머리 기호 형식 통일 +- 간결하고 명확한 구성 +""" + + # 더 명확한 시스템 프롬프트 + if use_korean: + system_prompt = f"""당신은 전문적인 PPT 프레젠테이션 작성 전문가입니다. +주어진 주제에 대해 정확히 {num_slides}장의 슬라이드 내용을 작성해주세요. + +**반드시 아래 형식을 정확히 따라주세요:** + +슬라이드 1 +제목: [슬라이드 제목 - "슬라이드 1" 같은 번호는 포함하지 마세요] +내용: +- 첫 번째 핵심 포인트 +- 두 번째 핵심 포인트 +- 세 번째 핵심 포인트 +- 네 번째 핵심 포인트 +- 다섯 번째 핵심 포인트 +노트: [발표자가 이 슬라이드를 설명할 때 사용할 구어체 스크립트] + +슬라이드 2 +제목: [슬라이드 제목] +내용: +- 첫 번째 핵심 포인트 +- 두 번째 핵심 포인트 +- 세 번째 핵심 포인트 +- 네 번째 핵심 포인트 +- 다섯 번째 핵심 포인트 +노트: [발표 스크립트] + +(이런 식으로 슬라이드 {num_slides}까지 계속) + +{layout_instructions} + +**중요 지침:** +1. 각 슬라이드는 "슬라이드 숫자"로 시작 +2. 제목: 뒤에 실제 제목 작성 (번호 제외) +3. 내용: 뒤에 정확히 5개의 글머리 기호 포인트 +4. 노트: 뒤에 발표 스크립트 +5. 각 섹션 사이에 빈 줄 없음 +6. 총 {num_slides}장 작성 +7. 각 포인트는 '-' 기호로 시작하세요 (이모지는 자동으로 추가됩니다) +8. 노트는 해당 슬라이드의 내용을 발표자가 청중에게 설명하는 구어체 대본으로 작성하세요""" + else: + system_prompt = f"""You are a professional PPT presentation expert. +Create content for exactly {num_slides} slides on the given topic. + +**You MUST follow this exact format:** + +Slide 1 +Title: [Slide title - do NOT include "Slide 1" in the title] +Content: +- First key point +- Second key point +- Third key point +- Fourth key point +- Fifth key point +Notes: [Speaker script in conversational style for explaining this slide] + +Slide 2 +Title: [Slide title] +Content: +- First key point +- Second key point +- Third key point +- Fourth key point +- Fifth key point +Notes: [Speaker script] + +(Continue this way until Slide {num_slides}) + +**Important instructions:** +1. Each slide starts with "Slide number" +2. Title: followed by the actual title (no numbers) +3. Content: followed by exactly 5 bullet points +4. Notes: followed by speaker script +5. No empty lines between sections +6. Create exactly {num_slides} slides +7. Start each point with '-' (emojis will be added automatically) +8. Notes should be a speaker script explaining the slide content in conversational style""" + + # Add search results if web search is performed + if additional_context: + system_prompt += f"\n\n참고 정보:\n{additional_context}" + + # Prepare messages + user_prompt = f"주제: {topic}\n\n위에서 설명한 형식에 맞춰 정확히 {num_slides}장의 PPT 슬라이드 내용을 작성해주세요. 각 슬라이드마다 5개의 핵심 포인트와 함께, 발표자가 청중에게 해당 내용을 설명하는 구어체 발표 대본을 노트로 작성해주세요." + if not use_korean: + user_prompt = f"Topic: {topic}\n\nPlease create exactly {num_slides} PPT slides following the format described above. Include exactly 5 key points per slide, and write speaker notes as a conversational script explaining the content to the audience." + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + + # Call LLM API + headers = { + "Authorization": f"Bearer {FRIENDLI_TOKEN}", + "Content-Type": "application/json" + } + + payload = { + "model": FRIENDLI_MODEL_ID, + "messages": messages, + "max_tokens": min(4000, num_slides * 300), # More tokens for 5 points + notes + "top_p": 0.9, + "temperature": 0.8, + "stream": True, + "stream_options": { + "include_usage": True + } + } + + try: + response = requests.post( + FRIENDLI_API_URL, + headers=headers, + json=payload, + stream=True, + timeout=60 + ) + response.raise_for_status() + + full_response = "" + for line in response.iter_lines(): + if line: + line_text = line.decode('utf-8') + if line_text.startswith("data: "): + data_str = line_text[6:] + if data_str == "[DONE]": + break + + try: + data = json.loads(data_str) + if "choices" in data and len(data["choices"]) > 0: + delta = data["choices"][0].get("delta", {}) + content = delta.get("content", "") + if content: + full_response += content + yield full_response + except json.JSONDecodeError: + logger.warning(f"JSON parsing failed: {data_str}") + continue + + except Exception as e: + logger.error(f"LLM API error: {str(e)}") + yield f"⚠️ Error generating content: {str(e)}" + +############################################################################## +# Main PPT Generation Function - IMPROVED VERSION with Enhanced Features +############################################################################## +def generate_ppt( + topic: str, + num_slides: int = 10, + use_web_search: bool = False, + use_korean: bool = True, + reference_files: list = None, + design_theme: str = "professional", + font_style: str = "modern", + layout_style: str = "consistent", + include_charts: bool = False, + include_ai_image: bool = False, + include_diagrams: bool = False, + include_flux_images: bool = False +) -> tuple: + """Main function to generate PPT with advanced design and enhanced visuals""" + + if not PPTX_AVAILABLE: + return None, "❌ python-pptx 라이브러리가 설치되지 않았습니다.\n\n설치 명령: pip install python-pptx", "" + + if not topic.strip(): + return None, "❌ PPT 주제를 입력해주세요.", "" + + if num_slides < 3 or num_slides > 20: + return None, "❌ 슬라이드 수는 3장 이상 20장 이하로 설정해주세요.", "" + + try: + # 3D 스타일 API 초기화 (표지 이미지용) + if include_ai_image and not AI_IMAGE_ENABLED: + yield None, "🔄 3D 스타일 이미지 생성 API에 연결하는 중...", "" + if initialize_ai_image_api(): + yield None, "✅ 3D 스타일 API 연결 성공!", "" + else: + include_ai_image = False + yield None, "⚠️ 3D 스타일 API 연결 실패. AI 이미지 없이 진행합니다.", "" + + # FLUX API 초기화 (포토리얼리스틱 이미지용) + if (include_ai_image or include_flux_images) and not FLUX_API_ENABLED: + yield None, "🔄 FLUX 포토리얼리스틱 API에 연결하는 중...", "" + if initialize_flux_api(): + yield None, "✅ FLUX API 연결 성공!", "" + else: + if include_ai_image and not AI_IMAGE_ENABLED: + include_ai_image = False + include_flux_images = False + yield None, "⚠️ FLUX API 연결 실패. 포토리얼리스틱 이미지 없이 진행합니다.", "" + + # 다이어그램 API 초기화 + if include_diagrams and not DIAGRAM_API_ENABLED: + yield None, "🔄 다이어그램 생성 API에 연결하는 중...", "" + if initialize_diagram_api(): + yield None, "✅ 다이어그램 API 연결 성공!", "" + else: + include_diagrams = False + yield None, "⚠️ 다이어그램 API 연결 실패. 다이어그램 없이 진행합니다.", "" + + # Process reference files if provided + additional_context = "" + chart_data = None + if reference_files: + file_contents = [] + for file_path in reference_files: + if file_path.lower().endswith(".csv"): + csv_content = analyze_csv_file(file_path) + file_contents.append(csv_content) + # Extract chart data if available + if "CHART_DATA:" in csv_content: + chart_json = csv_content.split("CHART_DATA:")[1] + try: + chart_data = json.loads(chart_json) + except: + pass + elif file_path.lower().endswith(".txt"): + file_contents.append(analyze_txt_file(file_path)) + elif file_path.lower().endswith(".pdf"): + file_contents.append(pdf_to_markdown(file_path)) + + if file_contents: + additional_context = "\n\n".join(file_contents) + + # Web search if enabled + if use_web_search: + search_query = extract_keywords(topic, top_k=5) + search_results = do_web_search(search_query, use_korean=use_korean) + if not search_results.startswith("Web search"): + additional_context += f"\n\n{search_results}" + + # Generate PPT content + llm_response = "" + for response in generate_ppt_content(topic, num_slides, additional_context, use_korean, layout_style): + llm_response = response + yield None, f"📝 생성 중...\n\n{response}", response + + # Parse LLM response + slides_data = parse_llm_ppt_response(llm_response, layout_style) + + # Debug: 파싱된 각 슬라이드 내용 출력 + logger.info(f"=== Parsed Slides Debug Info ===") + for i, slide in enumerate(slides_data): + logger.info(f"Slide {i+1}:") + logger.info(f" Title: {slide.get('title', 'NO TITLE')}") + logger.info(f" Content: {slide.get('content', 'NO CONTENT')[:100]}...") + logger.info(f" Content Length: {len(slide.get('content', ''))}") + logger.info("---") + + # Add chart data to relevant slides if available + if chart_data and include_charts: + for slide in slides_data: + if '데이터' in slide.get('title', '') or 'data' in slide.get('title', '').lower(): + slide['chart_data'] = chart_data + break + + # Debug logging + logger.info(f"Parsed {len(slides_data)} slides from LLM response") + logger.info(f"Design theme: {design_theme}, Layout style: {layout_style}") + logger.info(f"Include diagrams: {include_diagrams}, Include FLUX images: {include_flux_images}") + + if not slides_data: + # Show the raw response for debugging + error_msg = "❌ PPT 내용 파싱에 실패했습니다.\n\n" + error_msg += "LLM 응답을 확인해주세요:\n" + error_msg += "=" * 50 + "\n" + error_msg += llm_response[:500] + "..." if len(llm_response) > 500 else llm_response + yield None, error_msg, llm_response + return + + # AI 이미지 및 다이어그램 생성 알림 + visual_features = [] + if include_ai_image and FLUX_API_ENABLED: + visual_features.append("AI 3D 표지 이미지") + if include_diagrams and DIAGRAM_API_ENABLED: + visual_features.append("다이어그램") + if include_flux_images and FLUX_API_ENABLED: + visual_features.append("AI 생성 이미지") + + if visual_features: + yield None, f"📝 슬라이드 생성 완료!\n\n🎨 생성 중: {', '.join(visual_features)}... (시간이 소요될 수 있습니다)", llm_response + + # Create PPT file with advanced design + ppt_path = create_advanced_ppt_from_content( + slides_data, + topic, + design_theme, + include_charts, + include_ai_image, + include_diagrams, + include_flux_images + ) + + success_msg = f"✅ PPT 파일이 성공적으로 생성되었습니다!\n\n" + success_msg += f"📊 주제: {topic}\n" + success_msg += f"📄 슬라이드 수: {len(slides_data)}장\n" + success_msg += f"🎨 디자인 테마: {DESIGN_THEMES[design_theme]['name']}\n" + success_msg += f"📐 레이아웃 스타일: {layout_style}\n" + + if include_ai_image and FLUX_API_ENABLED: + success_msg += f"🖼️ AI 생성 표지 이미지 포함\n" + if include_diagrams and DIAGRAM_API_ENABLED: + success_msg += f"📊 AI 생성 다이어그램 포함\n" + if include_flux_images and FLUX_API_ENABLED: + success_msg += f"🎨 AI 생성 슬라이드 이미지 포함\n" + + success_msg += f"📝 생성된 슬라이드:\n" + + for i, slide in enumerate(slides_data[:5]): # Show first 5 slides + success_msg += f" {i+1}. {slide.get('title', '제목 없음')} [{slide.get('layout', 'standard')}]\n" + if slide.get('notes'): + success_msg += f" 💡 노트: {slide.get('notes', '')[:50]}...\n" + + if len(slides_data) > 5: + success_msg += f" ... �� {len(slides_data) - 5}장" + + yield ppt_path, success_msg, llm_response + + except Exception as e: + logger.error(f"PPT generation error: {str(e)}") + import traceback + error_details = traceback.format_exc() + logger.error(f"Error details: {error_details}") + yield None, f"❌ PPT 생성 중 오류 발생: {str(e)}\n\n상세 오류:\n{error_details}", "" + +############################################################################## +# Gradio UI +############################################################################## +css = """ +/* Full width UI */ +.gradio-container { + background: rgba(255, 255, 255, 0.98); + padding: 40px 50px; + margin: 30px auto; + width: 100% !important; + max-width: 1400px !important; + border-radius: 20px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); +} + +/* Background */ +body { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + margin: 0; + padding: 0; + font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; +} + +/* Title styling */ +h1 { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-weight: 700; + margin-bottom: 10px; +} + +/* Button styles */ +button.primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; + border: none; + color: white !important; + font-weight: 600; + padding: 15px 30px !important; + font-size: 18px !important; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; +} + +button.primary:hover { + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); +} + +/* Input styles */ +.textbox, textarea, input[type="text"], input[type="number"] { + border: 2px solid #e5e7eb; + border-radius: 12px; + padding: 15px; + font-size: 16px; + transition: all 0.3s ease; + background: white; +} + +.textbox:focus, textarea:focus, input[type="text"]:focus { + border-color: #667eea; + outline: none; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +/* Card style */ +.card { + background: white; + border-radius: 16px; + padding: 25px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); + margin-bottom: 25px; + border: 1px solid rgba(102, 126, 234, 0.1); +} + +/* Dropdown styles */ +.dropdown { + border: 2px solid #e5e7eb; + border-radius: 12px; + padding: 12px; + background: white; + transition: all 0.3s ease; +} + +.dropdown:hover { + border-color: #667eea; +} + +/* Slider styles */ +.gr-slider input[type="range"] { + background: linear-gradient(to right, #667eea 0%, #764ba2 100%); + height: 8px; + border-radius: 4px; +} + +/* File upload area */ +.file-upload { + border: 3px dashed #667eea; + border-radius: 16px; + padding: 40px; + text-align: center; + transition: all 0.3s ease; + background: rgba(102, 126, 234, 0.02); +} + +.file-upload:hover { + border-color: #764ba2; + background: rgba(102, 126, 234, 0.05); + transform: scale(1.01); +} + +/* Checkbox styles */ +input[type="checkbox"] { + width: 20px; + height: 20px; + margin-right: 10px; + cursor: pointer; +} + +/* Tab styles */ +.tabs { + border-radius: 12px; + overflow: hidden; + margin-bottom: 20px; +} + +.tab-nav { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 5px; +} + +.tab-nav button { + background: transparent; + color: white; + border: none; + padding: 10px 20px; + margin: 0 5px; + border-radius: 8px; + transition: all 0.3s ease; +} + +.tab-nav button.selected { + background: white; + color: #667eea; +} + +/* Section headers */ +.section-header { + font-size: 20px; + font-weight: 600; + color: #667eea; + margin: 20px 0 15px 0; + padding-bottom: 10px; + border-bottom: 2px solid rgba(102, 126, 234, 0.2); +} + +/* Status box styling */ +.status-box { + background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); + border-radius: 12px; + padding: 20px; +} + +/* Preview box styling */ +.preview-box { + background: #f8f9fa; + border-radius: 12px; + padding: 20px; + font-family: 'Courier New', monospace; + font-size: 13px; + line-height: 1.5; + max-height: 500px; + overflow-y: auto; +} +""" + +with gr.Blocks(css=css, title="AI PPT Generator Pro") as demo: + gr.Markdown( + """ + # 🎯 AI 기반 PPT 자동 생성 시스템 Pro + + 고급 디자인 테마와 레이아웃을 활용한 전문적인 프레젠테이션을 자동으로 생성합니다. + FLUX AI로 생성한 고품질 이미지와 다이어그램을 포함하여 시각적으로 풍부한 PPT를 만듭니다. + """ + ) + + with gr.Row(): + with gr.Column(scale=2): + topic_input = gr.Textbox( + label="📌 PPT 주제", + placeholder="예: 인공지능의 미래와 산업 적용 사례", + lines=2, + elem_classes="card" + ) + + with gr.Row(): + with gr.Column(): + num_slides = gr.Slider( + label="📄 슬라이드 수", + minimum=3, + maximum=20, + step=1, + value=10, + info="생성할 슬라이드 개수 (3-20장)" + ) + + with gr.Column(): + use_korean = gr.Checkbox( + label="🇰🇷 한국어", + value=True, + info="한국어로 생성" + ) + + use_web_search = gr.Checkbox( + label="🔍 웹 검색", + value=False, + info="최신 정보 검색" + ) + + # Design Options Section + gr.Markdown("
🎨 디자인 옵션
") + + with gr.Row(): + design_theme = gr.Dropdown( + label="디자인 테마", + choices=[ + ("프로페셔널 (파랑/회색)", "professional"), + ("모던 (보라/핑크)", "modern"), + ("자연 (초록/갈색)", "nature"), + ("크리에이티브 (다채로운)", "creative"), + ("미니멀 (흑백)", "minimal") + ], + value="professional", + elem_classes="dropdown" + ) + + layout_style = gr.Dropdown( + label="레이아웃 스타일", + choices=[ + ("일관된 레이아웃", "consistent"), + ("다양한 레이아웃", "varied"), + ("미니멀 레이아웃", "minimal") + ], + value="consistent", + elem_classes="dropdown" + ) + + with gr.Row(): + font_style = gr.Dropdown( + label="폰트 스타일", + choices=[ + ("모던", "modern"), + ("클래식", "classic"), + ("캐주얼", "casual") + ], + value="modern", + elem_classes="dropdown" + ) + + include_charts = gr.Checkbox( + label="📊 차트 포함", + value=False, + info="CSV 데이터가 있을 경우 차트 생성" + ) + + # Visual Enhancement Options + gr.Markdown("
🖼️ 시각적 향상 옵션
") + + with gr.Row(): + include_ai_image = gr.Checkbox( + label="🖼️ AI 표지 이미지", + value=False, + info="FLUX로 생성한 표지 이미지 추가" + ) + + include_diagrams = gr.Checkbox( + label="📊 AI 다이어그램", + value=False, + info="내용에 맞는 다이어그램 자동 생성" + ) + + include_flux_images = gr.Checkbox( + label="🎨 슬라이드 이미지", + value=False, + info="일부 슬라이드에 FLUX 이미지 추가" + ) + + reference_files = gr.File( + label="📎 참고 자료 (선택사항)", + file_types=[".pdf", ".csv", ".txt"], + file_count="multiple", + elem_classes="file-upload" + ) + + generate_btn = gr.Button( + "🚀 PPT 생성하기", + variant="primary", + size="lg" + ) + + with gr.Column(scale=1): + download_file = gr.File( + label="📥 생성된 PPT 다운로드", + interactive=False, + elem_classes="card" + ) + + status_text = gr.Textbox( + label="📊 생성 상태", + lines=10, + interactive=False, + elem_classes="status-box" + ) + + with gr.Row(): + content_preview = gr.Textbox( + label="📝 생성된 내용 미리보기", + lines=20, + interactive=False, + visible=True, + elem_classes="preview-box" + ) + + gr.Markdown( + """ + ### 📋 사용 방법 + 1. **PPT 주제 ���력**: 구체적인 주제일수록 더 좋은 결과 + 2. **슬라이드 수 선택**: 3-20장 범위에서 선택 + 3. **디자인 테마 선택**: 5가지 전문적인 테마 중 선택 + 4. **시각적 옵션 설정**: AI 이미지, 다이어그램, FLUX 이미지 추가 + 5. **참고 자료 업로드**: PDF, CSV, TXT 파일 지원 + 6. **생성 버튼 클릭**: AI가 자동으로 PPT 생성 + + ### 🎨 새로운 기능 + - **병렬 AI 이미지 생성**: 3D 스타일과 포토리얼리스틱 API를 동시에 사용하여 생성 시간 단축 + - **다양한 이미지 스타일**: 각 슬라이드마다 다른 스타일과 메타포로 이미지 생성 + - **AI 표지 이미지**: 우측 하단 배치로 텍스트와 겹치지 않음 + - **결론 슬라이드 강조**: 결론/요약 슬라이드에 특별한 이미지 추가 + - **좌-우 레이아웃**: 텍스트는 좌측, 시각적 요소는 우측 배치 + + ### 💡 고급 팁 + - 표지 이미지는 우측 하단에 배치되어 제목/부제와 겹치지 않습니다 + - 3D 스타일과 포토리얼리스틱 이미지가 병렬로 생성되어 시간이 절약됩니다 + - 각 슬라이드는 내용에 맞는 고유한 시각 메타포와 스타일로 이미지가 생성됩니다 + - 결론/요약 슬라이드는 자동으로 감지되어 특별한 이미지가 추가됩니다 + """ + ) + + # Examples + gr.Examples( + examples=[ + ["인공지능의 미래와 산업 적용 사례", 10, False, True, [], "professional", "modern", "consistent", False, True, True, False], + ["2024년 디지털 마케팅 트렌드", 12, True, True, [], "modern", "modern", "consistent", False, True, True, True], + ["기후변화와 지속가능한 발전", 15, True, True, [], "nature", "classic", "consistent", False, True, True, True], + ["스타트업 사업계획서", 8, False, True, [], "creative", "modern", "varied", False, True, True, True], + ], + inputs=[topic_input, num_slides, use_web_search, use_korean, reference_files, + design_theme, font_style, layout_style, include_charts, include_ai_image, + include_diagrams, include_flux_images], + ) + + # Event handler + generate_btn.click( + fn=generate_ppt, + inputs=[ + topic_input, + num_slides, + use_web_search, + use_korean, + reference_files, + design_theme, + font_style, + layout_style, + include_charts, + include_ai_image, + include_diagrams, + include_flux_images + ], + outputs=[download_file, status_text, content_preview] + ) + +# Initialize APIs on startup +if __name__ == "__main__": + # Try to initialize APIs in parallel + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + futures = [] + if AI_IMAGE_API_URL: + futures.append(executor.submit(initialize_ai_image_api)) + if FLUX_API_URL: + futures.append(executor.submit(initialize_flux_api)) + if DIAGRAM_API_URL: + futures.append(executor.submit(initialize_diagram_api)) + + # Wait for all to complete + for future in concurrent.futures.as_completed(futures): + try: + future.result() + except Exception as e: + logger.error(f"API initialization failed: {e}") + + demo.launch() \ No newline at end of file