Spaces:
Sleeping
Sleeping
| """ | |
| Gerador de apresentacoes PPTX com slides HTML renderizados (visual rico). | |
| Pipeline: Claude gera HTML/CSS bonitos -> Playwright renderiza PNG -> monta PPTX com imagens. | |
| """ | |
| import asyncio | |
| import gc | |
| import io | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import tempfile | |
| import time | |
| from pptx import Presentation | |
| from pptx.util import Inches, Emu | |
| from modules.config import client_anthropic | |
| # --------------------------------------------------------------------------- | |
| # Constants | |
| # --------------------------------------------------------------------------- | |
| _SLIDE_WIDTH_PX = 1920 | |
| _SLIDE_HEIGHT_PX = 1080 | |
| _CORA_BRAND = { | |
| "pink": "#FE3E6D", | |
| "pink_light": "#FF6B8A", | |
| "dark": "#1A1A2E", | |
| "dark2": "#16213E", | |
| "gray": "#F5F5F7", | |
| "text": "#2D2D2D", | |
| "white": "#FFFFFF", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # System prompt — foco no RACIONAL das iniciativas por KR | |
| # --------------------------------------------------------------------------- | |
| _SYSTEM_PROMPT = """Voce e um designer de apresentacoes executivas de altissimo nivel. | |
| Crie slides LINDOS em HTML/CSS para uma apresentacao da **Cora** (banco digital). | |
| OBJETIVO DA APRESENTACAO: | |
| - Mostrar o RACIONAL de priorizacao: para cada KR, explicar POR QUE as iniciativas foram priorizadas | |
| - NAO mostrar roadmap/timeline — foque no raciocinio estrategico | |
| - Tom executivo e direto | |
| - FOCO EXCLUSIVO nas iniciativas relacionadas aos OKRs/KRs — NAO incluir temas regulatorios, auditoria, compliance ou sustentacao que nao estejam diretamente ligados a um KR | |
| ESTRUTURA IDEAL: | |
| 1. Slide de capa impactante | |
| 2. Para cada KR relevante: 1-2 slides explicando: | |
| - O KR apenas com nome e meta bruta (ex: "Reduzir FPR — meta: 0,22%"). NAO mostrar valor atual, NAO mostrar % de atingimento, NAO mostrar status (off-track/at-risk), NAO mostrar evolucao mensal. Apenas o NOME do KR e a META. | |
| - As iniciativas priorizadas que impactam esse KR | |
| - O MECANISMO de impacto (a logica de POR QUE a iniciativa ajuda) | |
| - Exemplo: "Biometria → permite usuario desbloquear conta propria → reduz bloqueios indevidos de clientes bons" | |
| 3. OBRIGATORIO: Slide "Mapa de Correlacao — Iniciativas x Key Results" em formato de TABELA HTML pura: | |
| - Fundo escuro (gradiente {dark} para {dark2}), texto branco | |
| - Titulo grande no topo: "Mapa de Correlacao — Iniciativas x Key Results" com emoji 📊 | |
| - Subtitulo: "Como cada iniciativa contribui para os KRs. Impacto primario (●) e secundario (○) mapeados." | |
| - Tabela com header: INICIATIVA | KR1 | KR2 | KR3 | SUSTENTACAO/COMPLIANCE | |
| - Linhas agrupadas por CATEGORIA em negrito maiusculo (ex: "FRICCAO INTELIGENTE (KR1)") | |
| - Cada iniciativa com emoji no inicio do nome | |
| - Celulas: circulo rosa cheio (●) = impacto PRIMARIO, circulo rosa vazado (○) = impacto SECUNDARIO, vazio = sem impacto | |
| - Bordas sutis entre linhas (rgba branco 10%) | |
| - Rodape discreto "Cora | Risco e Seguranca" | |
| 4. Slide de encerramento | |
| REGRAS DE DESIGN: | |
| 1. Cada slide e um HTML COMPLETO, self-contained, com CSS inline no <style> dentro do <head> | |
| 2. Viewport FIXO: {width}x{height}px (16:9 Full HD) | |
| 3. Brand Cora OBRIGATORIO: | |
| - Rosa principal: {pink} | |
| - Rosa claro: {pink_light} | |
| - Fundo escuro: gradiente de {dark} para {dark2} | |
| - Fundo alternativo: branco {white} com acentos rosa | |
| - Texto claro em fundo escuro, texto escuro ({text}) em fundo branco | |
| - Fonte: system-ui, -apple-system, 'Segoe UI', sans-serif | |
| 4. Alterne entre slides com fundo escuro e fundo claro para variedade visual | |
| 5. Use icones Unicode/emoji quando apropriado (📊 📈 🎯 ✅ 🔒 ⚡ 🚀 🛡️) | |
| 6. Para listas, use layout em GRID/CARDS — NAO use listas simples com bullets | |
| 7. Use setas (→) para mostrar cadeia de impacto | |
| 8. Adicione rodape discreto "Cora | Risco e Seguranca" em cada slide (exceto capa) | |
| 9. MAXIMO 3-4 topicos por slide — se tiver mais, quebre em slides adicionais | |
| 10. Textos CURTOS e impactantes — maximo 2 linhas por topico | |
| REGRAS DE CONTEUDO: | |
| 1. Tudo em portugues brasileiro | |
| 2. NAO copie OKRs literalmente — reescreva de forma clara e executiva | |
| 3. NAO inclua datas ou timeline do roadmap | |
| 4. SEMPRE explique o MECANISMO: Iniciativa → Como funciona → Impacto no KR | |
| 5. PROIBIDO mostrar dados de acompanhamento: valor atual, % de atingimento, status (off-track/at-risk/on-track), gap analysis, evolucao mensal, comparativo mes a mes. Mostre APENAS nome do KR + meta bruta | |
| 6. NAO mencionar CPO, diretoria ou audiencia no conteudo dos slides | |
| 7. IGNORAR temas regulatorios, auditoria, compliance e sustentacao — focar APENAS nas iniciativas dos KRs | |
| 8. Seja especifico e concreto — nao generico | |
| 9. Se houver PRDs no contexto, use o OBJETIVO descrito no PRD para embasar o racional da iniciativa — o PRD detalha bem o proposito e impacto esperado de cada iniciativa. NAO copie o PRD, mas extraia a essencia do objetivo para explicar o POR QUE | |
| FORMATO DE SAIDA — JSON puro, sem markdown, sem blocos de codigo: | |
| [ | |
| {{ | |
| "titulo": "Titulo do slide", | |
| "html": "<!DOCTYPE html><html>...</html>" | |
| }} | |
| ] | |
| IMPORTANTE: | |
| - Retorne APENAS o JSON array, sem NENHUM texto antes ou depois | |
| - O HTML deve ser COMPLETO (<!DOCTYPE html>, <html>, <head> com <style>, <body>) | |
| - Cada slide deve ter width:{width}px e height:{height}px fixos no body (overflow:hidden) | |
| - NAO use JavaScript — apenas HTML e CSS puro | |
| - NAO use imagens externas ou Google Fonts — apenas system fonts e emoji | |
| - Gere entre 8 e 15 slides dependendo da quantidade de conteudo""" | |
| # --------------------------------------------------------------------------- | |
| # 1. Generate HTML slides with Claude | |
| # --------------------------------------------------------------------------- | |
| def _gerar_slides_html(tema: str, contexto: str, instrucoes: str = "") -> list[dict]: | |
| """Usa Opus para gerar slides HTML bonitos.""" | |
| system = _SYSTEM_PROMPT.format( | |
| width=_SLIDE_WIDTH_PX, | |
| height=_SLIDE_HEIGHT_PX, | |
| **_CORA_BRAND, | |
| ) | |
| user_msg = f"TEMA DA APRESENTACAO: {tema}\n\n" | |
| if contexto: | |
| user_msg += f"CONTEXTO DISPONIVEL:\n{contexto}\n\n" | |
| if instrucoes: | |
| user_msg += f"INSTRUCOES ADICIONAIS: {instrucoes}\n\n" | |
| user_msg += "Gere a apresentacao em JSON com slides HTML agora." | |
| # Stream with auto-continuation (slides can be long) | |
| resposta = "" | |
| messages = [{"role": "user", "content": user_msg}] | |
| max_continuacoes = 4 | |
| start = time.time() | |
| for tentativa in range(max_continuacoes + 1): | |
| if time.time() - start > 300: | |
| break | |
| chunks = [] | |
| stop_reason = None | |
| try: | |
| with client_anthropic.messages.stream( | |
| model="claude-opus-4-6", | |
| max_tokens=16384, | |
| system=system, | |
| messages=messages, | |
| timeout=300, | |
| ) as stream: | |
| for delta in stream.text_stream: | |
| chunks.append(delta) | |
| stop_reason = stream.get_final_message().stop_reason | |
| except Exception as e: | |
| logging.error(f"[PPTX Gen] Stream error: {e}") | |
| if resposta: | |
| break | |
| raise | |
| resposta += "".join(chunks) | |
| if stop_reason != "max_tokens" or tentativa == max_continuacoes: | |
| break | |
| # Continue from where we left off | |
| messages = [ | |
| {"role": "user", "content": user_msg}, | |
| {"role": "assistant", "content": resposta}, | |
| {"role": "user", "content": "Continue o JSON exatamente de onde parou. Nao repita o que ja foi escrito."}, | |
| ] | |
| # Parse JSON | |
| resposta = resposta.strip() | |
| if resposta.startswith("```"): | |
| resposta = resposta.split("\n", 1)[1] if "\n" in resposta else resposta[3:] | |
| if resposta.endswith("```"): | |
| resposta = resposta[:-3] | |
| resposta = resposta.strip() | |
| def _sanitize_json(raw: str) -> str: | |
| """Fix invalid control characters inside JSON string values. | |
| Replaces ALL control chars (including literal newlines/tabs inside strings).""" | |
| def _escape_controls(m): | |
| ch = m.group(0) | |
| return {"\n": "\\n", "\r": "\\r", "\t": "\\t"}.get(ch, f"\\u{ord(ch):04x}") | |
| return re.sub(r'[\x00-\x1f]', _escape_controls, raw) | |
| # Strategy: try multiple approaches in order of reliability | |
| parse_errors = [] | |
| # 1. Try strict=False first (handles control chars natively) | |
| try: | |
| slides = json.loads(resposta, strict=False) | |
| return slides if isinstance(slides, list) and slides else None | |
| except json.JSONDecodeError as e: | |
| parse_errors.append(f"strict=False: {e}") | |
| # 2. Try sanitized version | |
| sanitized = _sanitize_json(resposta) | |
| try: | |
| slides = json.loads(sanitized) | |
| return slides if isinstance(slides, list) and slides else None | |
| except json.JSONDecodeError as e: | |
| parse_errors.append(f"sanitized: {e}") | |
| # 3. Try to fix truncated JSON | |
| for fix in ["]", "}]", "\"}]", "\"\n}]", "\"\n }\n]"]: | |
| try: | |
| slides = json.loads(sanitized + fix) | |
| logging.warning(f"[PPTX Gen] Fixed truncated JSON with: {fix}") | |
| if isinstance(slides, list) and slides: | |
| return slides | |
| except json.JSONDecodeError: | |
| continue | |
| logging.error(f"[PPTX Gen] All JSON parse attempts failed: {parse_errors}") | |
| logging.error(f"[PPTX Gen] Response (first 500): {resposta[:500]}") | |
| raise ValueError(f"Nao foi possivel parsear o JSON dos slides: {parse_errors[-1]}") | |
| # --------------------------------------------------------------------------- | |
| # 2. Render HTML slides to PNG with Playwright | |
| # --------------------------------------------------------------------------- | |
| async def _renderizar_slides_async(slides: list[dict], tmp_dir: str) -> list[str]: | |
| """Renders each slide HTML to PNG using Playwright.""" | |
| from playwright.async_api import async_playwright | |
| png_paths = [] | |
| async with async_playwright() as p: | |
| browser = await p.chromium.launch( | |
| headless=True, | |
| args=["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], | |
| ) | |
| page = await browser.new_page( | |
| viewport={"width": _SLIDE_WIDTH_PX, "height": _SLIDE_HEIGHT_PX}, | |
| ) | |
| for i, slide in enumerate(slides): | |
| html = slide.get("html", "") | |
| if not html: | |
| continue | |
| html_path = os.path.join(tmp_dir, f"slide_{i:03d}.html") | |
| with open(html_path, "w", encoding="utf-8") as f: | |
| f.write(html) | |
| await page.goto(f"file://{html_path}", wait_until="networkidle") | |
| await page.wait_for_timeout(300) | |
| png_path = os.path.join(tmp_dir, f"slide_{i:03d}.png") | |
| await page.screenshot(path=png_path, type="png") | |
| png_paths.append(png_path) | |
| await browser.close() | |
| return png_paths | |
| def _renderizar_slides(slides: list[dict], tmp_dir: str) -> list[str]: | |
| """Sync wrapper for async Playwright rendering.""" | |
| loop = asyncio.new_event_loop() | |
| try: | |
| return loop.run_until_complete( | |
| _renderizar_slides_async(slides, tmp_dir) | |
| ) | |
| finally: | |
| loop.close() | |
| # --------------------------------------------------------------------------- | |
| # 3. Mount PPTX with full-bleed PNG images | |
| # --------------------------------------------------------------------------- | |
| def _montar_pptx(png_paths: list[str]) -> bytes: | |
| """Creates PPTX with each slide being a full-bleed PNG image.""" | |
| prs = Presentation() | |
| # 16:9 widescreen | |
| prs.slide_width = Inches(13.333) | |
| prs.slide_height = Inches(7.5) | |
| for png_path in png_paths: | |
| slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout | |
| # Add image covering entire slide | |
| slide.shapes.add_picture( | |
| png_path, | |
| left=Emu(0), | |
| top=Emu(0), | |
| width=prs.slide_width, | |
| height=prs.slide_height, | |
| ) | |
| bio = io.BytesIO() | |
| prs.save(bio) | |
| bio.seek(0) | |
| del prs | |
| gc.collect() | |
| return bio.getvalue() | |
| # --------------------------------------------------------------------------- | |
| # PUBLIC API | |
| # --------------------------------------------------------------------------- | |
| def gerar_pptx(tema: str, contexto: str = "", instrucoes: str = "", | |
| progress_callback=None) -> tuple[bytes, int]: | |
| """ | |
| Gera apresentacao PPTX com slides HTML renderizados (visual rico). | |
| Pipeline: | |
| 1. Claude Opus gera HTML/CSS bonitos para cada slide | |
| 2. Playwright renderiza cada HTML em PNG (1920x1080) | |
| 3. Monta PPTX com imagens full-bleed | |
| Args: | |
| tema: tema/titulo da apresentacao | |
| contexto: contexto carregado automaticamente (OKRs, roadmap, etc.) | |
| instrucoes: instrucoes adicionais do usuario | |
| progress_callback: funcao(step, total, msg) | |
| Returns: | |
| (bytes do PPTX, numero de slides) | |
| """ | |
| def _progress(step, total, msg): | |
| if progress_callback: | |
| progress_callback(step, total, msg) | |
| # 1. Gerar HTML slides com Claude | |
| _progress(1, 4, "Claude Opus esta criando os slides HTML com design visual...") | |
| slides = _gerar_slides_html(tema, contexto, instrucoes) | |
| n_slides = len(slides) | |
| logging.info(f"[PPTX Gen] {n_slides} slides HTML gerados pelo Claude") | |
| with tempfile.TemporaryDirectory(prefix="pptx_gen_") as tmp_dir: | |
| # 2. Renderizar HTML -> PNG com Playwright | |
| _progress(2, 4, f"Renderizando {n_slides} slides com Playwright...") | |
| png_paths = _renderizar_slides(slides, tmp_dir) | |
| logging.info(f"[PPTX Gen] {len(png_paths)} slides renderizados em PNG") | |
| # 3. Montar PPTX | |
| _progress(3, 4, f"Montando PPTX com {len(png_paths)} slides...") | |
| pptx_bytes = _montar_pptx(png_paths) | |
| # 4. Finalizar | |
| _progress(4, 4, "Apresentacao pronta!") | |
| gc.collect() | |
| return pptx_bytes, n_slides, slides | |
| def editar_slide(slides: list[dict], slide_idx: int, instrucao: str, | |
| progress_callback=None) -> tuple[bytes, int, list[dict]]: | |
| """ | |
| Edita um slide especifico e remonta o PPTX. | |
| Args: | |
| slides: lista de slides (titulo + html) da geracao anterior | |
| slide_idx: indice do slide a editar (0-based) | |
| instrucao: o que o usuario quer mudar nesse slide | |
| progress_callback: funcao(step, total, msg) | |
| Returns: | |
| (bytes do PPTX, numero de slides, slides atualizados) | |
| """ | |
| def _progress(step, total, msg): | |
| if progress_callback: | |
| progress_callback(step, total, msg) | |
| slide_atual = slides[slide_idx] | |
| _progress(1, 4, f"Editando slide {slide_idx + 1}: '{slide_atual.get('titulo', '')}'...") | |
| system = f"""Voce e um designer de apresentacoes. Recebeu um slide HTML existente e precisa EDITA-LO conforme a instrucao do usuario. | |
| REGRAS: | |
| 1. Mantenha o mesmo estilo visual (cores, fontes, layout geral) do slide original | |
| 2. Faca APENAS as alteracoes pedidas pelo usuario — nao mude o resto | |
| 3. O slide deve ter {_SLIDE_WIDTH_PX}x{_SLIDE_HEIGHT_PX}px fixos | |
| 4. Retorne APENAS o JSON do slide editado, sem texto antes ou depois | |
| FORMATO DE SAIDA — JSON puro: | |
| {{ | |
| "titulo": "Titulo do slide", | |
| "html": "<!DOCTYPE html><html>...</html>" | |
| }}""" | |
| user_msg = f"""SLIDE ATUAL (indice {slide_idx}): | |
| Titulo: {slide_atual.get('titulo', '')} | |
| HTML: {slide_atual.get('html', '')} | |
| INSTRUCAO DE EDICAO: {instrucao} | |
| Retorne o slide editado em JSON.""" | |
| try: | |
| resposta = "" | |
| with client_anthropic.messages.stream( | |
| model="claude-opus-4-6", | |
| max_tokens=8192, | |
| system=system, | |
| messages=[{"role": "user", "content": user_msg}], | |
| timeout=120, | |
| ) as stream: | |
| for delta in stream.text_stream: | |
| resposta += delta | |
| except Exception as e: | |
| logging.error(f"[PPTX Edit] Stream error: {e}") | |
| raise | |
| # Parse JSON | |
| resposta = resposta.strip() | |
| if resposta.startswith("```"): | |
| resposta = resposta.split("\n", 1)[1] if "\n" in resposta else resposta[3:] | |
| if resposta.endswith("```"): | |
| resposta = resposta[:-3] | |
| resposta = resposta.strip() | |
| try: | |
| novo_slide = json.loads(resposta, strict=False) | |
| if isinstance(novo_slide, list) and novo_slide: | |
| novo_slide = novo_slide[0] | |
| except json.JSONDecodeError as e: | |
| logging.error(f"[PPTX Edit] JSON parse error: {e}") | |
| raise ValueError(f"Erro ao parsear slide editado: {e}") | |
| # Substituir slide na lista | |
| slides_atualizados = list(slides) | |
| slides_atualizados[slide_idx] = novo_slide | |
| logging.info(f"[PPTX Edit] Slide {slide_idx} editado: '{novo_slide.get('titulo', '')}'") | |
| # Re-render e remontar | |
| _progress(2, 4, f"Renderizando {len(slides_atualizados)} slides...") | |
| with tempfile.TemporaryDirectory(prefix="pptx_edit_") as tmp_dir: | |
| png_paths = _renderizar_slides(slides_atualizados, tmp_dir) | |
| _progress(3, 4, "Montando PPTX...") | |
| pptx_bytes = _montar_pptx(png_paths) | |
| _progress(4, 4, "Edicao concluida!") | |
| gc.collect() | |
| return pptx_bytes, len(slides_atualizados), slides_atualizados | |
| def _extrair_slides_mencionados(instrucao: str, total_slides: int) -> list[int]: | |
| """Extrai indices (0-based) dos slides mencionados na instrucao. | |
| Se nenhum slide especifico for mencionado, retorna todos.""" | |
| # Match patterns like "Slide 1", "slide 3", "Slide 10" | |
| matches = re.findall(r'[Ss]lide\s+(\d+)', instrucao) | |
| if matches: | |
| indices = set() | |
| for m in matches: | |
| num = int(m) - 1 # Convert 1-based to 0-based | |
| if 0 <= num < total_slides: | |
| indices.add(num) | |
| return sorted(indices) if indices else list(range(total_slides)) | |
| # No specific slides mentioned — apply to all | |
| return list(range(total_slides)) | |
| def editar_slides_lote(slides: list[dict], instrucao: str, | |
| progress_callback=None) -> tuple[bytes, int, list[dict]]: | |
| """ | |
| Edita slides referenciados na instrucao, um por vez. | |
| Slides nao mencionados sao mantidos intactos (sem reprocessar). | |
| """ | |
| def _progress(step, total, msg): | |
| if progress_callback: | |
| progress_callback(step, total, msg) | |
| # Identificar quais slides precisam ser editados | |
| indices_editar = _extrair_slides_mencionados(instrucao, len(slides)) | |
| logging.info(f"[PPTX Edit Lote] Slides a editar: {[i+1 for i in indices_editar]} de {len(slides)}") | |
| slides_atualizados = list(slides) # copia — slides nao mencionados ficam intactos | |
| total_steps = len(indices_editar) + 2 # edits + render + mount | |
| step = 0 | |
| for idx in indices_editar: | |
| step += 1 | |
| slide_atual = slides[idx] | |
| titulo = slide_atual.get("titulo", f"Slide {idx+1}") | |
| _progress(step, total_steps, f"Editando slide {idx+1}: '{titulo}'...") | |
| system = f"""Voce e um designer de apresentacoes. Recebeu um slide HTML existente e precisa EDITA-LO conforme a instrucao do usuario. | |
| REGRAS: | |
| 1. Mantenha o mesmo estilo visual (cores, fontes, layout geral) do slide original | |
| 2. Faca APENAS as alteracoes pedidas pelo usuario — nao mude o resto | |
| 3. O slide deve ter {_SLIDE_WIDTH_PX}x{_SLIDE_HEIGHT_PX}px fixos | |
| 4. Retorne APENAS o JSON do slide editado, sem texto antes ou depois | |
| FORMATO DE SAIDA — JSON puro: | |
| {{ | |
| "titulo": "Titulo do slide", | |
| "html": "<!DOCTYPE html><html>...</html>" | |
| }}""" | |
| user_msg = f"""SLIDE ATUAL (Slide {idx+1}): | |
| Titulo: {titulo} | |
| HTML: {slide_atual.get('html', '')} | |
| INSTRUCAO DE EDICAO (pode mencionar outros slides — aplique APENAS o que se refere ao Slide {idx+1}): | |
| {instrucao} | |
| Retorne o slide editado em JSON.""" | |
| try: | |
| resposta = "" | |
| with client_anthropic.messages.stream( | |
| model="claude-opus-4-6", | |
| max_tokens=8192, | |
| system=system, | |
| messages=[{"role": "user", "content": user_msg}], | |
| timeout=120, | |
| ) as stream: | |
| for delta in stream.text_stream: | |
| resposta += delta | |
| except Exception as e: | |
| logging.error(f"[PPTX Edit Lote] Slide {idx+1} stream error: {e}") | |
| continue # Manter slide original se falhar | |
| # Parse JSON | |
| resposta = resposta.strip() | |
| if resposta.startswith("```"): | |
| resposta = resposta.split("\n", 1)[1] if "\n" in resposta else resposta[3:] | |
| if resposta.endswith("```"): | |
| resposta = resposta[:-3] | |
| resposta = resposta.strip() | |
| try: | |
| novo_slide = json.loads(resposta, strict=False) | |
| if isinstance(novo_slide, list) and novo_slide: | |
| novo_slide = novo_slide[0] | |
| slides_atualizados[idx] = novo_slide | |
| logging.info(f"[PPTX Edit Lote] Slide {idx+1} editado: '{novo_slide.get('titulo', '')}'") | |
| except (json.JSONDecodeError, ValueError) as e: | |
| logging.error(f"[PPTX Edit Lote] Slide {idx+1} JSON parse error: {e}") | |
| # Manter slide original | |
| # Re-render todos os slides (editados + nao editados) | |
| step += 1 | |
| _progress(step, total_steps, f"Renderizando {len(slides_atualizados)} slides...") | |
| with tempfile.TemporaryDirectory(prefix="pptx_edit_") as tmp_dir: | |
| png_paths = _renderizar_slides(slides_atualizados, tmp_dir) | |
| _progress(total_steps, total_steps, "Montando PPTX...") | |
| pptx_bytes = _montar_pptx(png_paths) | |
| gc.collect() | |
| return pptx_bytes, len(slides_atualizados), slides_atualizados | |
| # --------------------------------------------------------------------------- | |
| # Drive persistence for presentations | |
| # --------------------------------------------------------------------------- | |
| def _get_apresentacoes_folder(): | |
| """Get or create the 'apresentacoes' folder ID.""" | |
| from modules.drive_utils import get_folders | |
| folders = get_folders() | |
| if folders and folders.get("apresentacoes"): | |
| return folders["apresentacoes"] | |
| # Fallback: initialize structure to create folder | |
| from modules.drive_utils import conectar_drive, drive_inicializar_estrutura | |
| svc = conectar_drive() | |
| if svc: | |
| folders = drive_inicializar_estrutura(svc) | |
| return folders.get("apresentacoes") | |
| return None | |
| def salvar_apresentacao_drive(slides: list[dict], nome: str) -> str | None: | |
| """Salva slides HTML como JSON no Drive (pasta apresentacoes).""" | |
| from modules.drive_utils import ( | |
| conectar_drive_usuario, conectar_drive, | |
| drive_upload_arquivo, drive_listar_arquivos, drive_atualizar_arquivo, | |
| ) | |
| try: | |
| folder_id = _get_apresentacoes_folder() | |
| if not folder_id: | |
| logging.warning("[PPTX Save] Pasta apresentacoes nao encontrada") | |
| return None | |
| # Sanitize name | |
| import re as _re | |
| safe_name = _re.sub(r'[^\w\s\-]', '', nome)[:50].strip() or "apresentacao" | |
| file_name = f"pptx_{safe_name}.json" | |
| payload = json.dumps({"nome": nome, "slides": slides}, ensure_ascii=False) | |
| content_bytes = payload.encode("utf-8") | |
| # Check if file already exists (update instead of duplicate) | |
| svc_read = conectar_drive() | |
| if svc_read: | |
| existing = drive_listar_arquivos(svc_read, folder_id) | |
| for arq in existing: | |
| if arq["name"] == file_name: | |
| drive_atualizar_arquivo(svc_read, arq["id"], content_bytes, "application/json") | |
| logging.info(f"[PPTX Save] Apresentacao atualizada: {file_name}") | |
| return arq["id"] | |
| # Upload new | |
| svc_write = conectar_drive_usuario() or svc_read | |
| if svc_write: | |
| fid = drive_upload_arquivo(svc_write, file_name, content_bytes, | |
| "application/json", folder_id) | |
| logging.info(f"[PPTX Save] Apresentacao salva: {file_name} (id={fid})") | |
| return fid | |
| except Exception as e: | |
| logging.error(f"[PPTX Save] Erro ao salvar: {e}") | |
| return None | |
| def listar_apresentacoes_drive() -> list[dict]: | |
| """Lista apresentacoes salvas no Drive.""" | |
| from modules.drive_utils import conectar_drive, conectar_drive_usuario, drive_listar_arquivos | |
| try: | |
| folder_id = _get_apresentacoes_folder() | |
| if not folder_id: | |
| logging.warning("[PPTX List] Pasta apresentacoes nao encontrada") | |
| return [] | |
| # Try user OAuth first (sees files created by user), then Service Account | |
| svc = conectar_drive_usuario() or conectar_drive() | |
| if not svc: | |
| logging.warning("[PPTX List] Nenhum servico Drive disponivel") | |
| return [] | |
| arquivos = drive_listar_arquivos(svc, folder_id) | |
| logging.info(f"[PPTX List] Encontrados {len(arquivos)} arquivos na pasta apresentacoes") | |
| # Accept both .json (editable) and .pptx (importable) files | |
| result = [a for a in arquivos if not a.get("mimeType", "").startswith("application/vnd.google-apps.folder")] | |
| return result | |
| except Exception as e: | |
| logging.error(f"[PPTX List] Erro: {e}") | |
| return [] | |
| def carregar_apresentacao_drive(file_id: str, file_name: str = "") -> list[dict] | None: | |
| """Carrega slides HTML de uma apresentacao salva no Drive. | |
| Se for .json, carrega direto. Se for .pptx ou Google Slides, extrai imagens e recria HTML via Claude. | |
| """ | |
| from modules.drive_utils import conectar_drive, conectar_drive_usuario | |
| try: | |
| logging.info(f"[PPTX Load] Iniciando carregamento: file_id={file_id}, file_name={file_name}") | |
| svc = conectar_drive_usuario() or conectar_drive() | |
| if not svc: | |
| logging.error("[PPTX Load] Nenhum servico Drive disponivel") | |
| return None | |
| # Check mimeType to handle Google Slides vs binary files | |
| logging.info("[PPTX Load] Buscando metadata do arquivo...") | |
| file_meta = svc.files().get(fileId=file_id, fields="mimeType,name,size", | |
| supportsAllDrives=True).execute() | |
| mime = file_meta.get("mimeType", "") | |
| name = file_meta.get("name", file_name) | |
| size = file_meta.get("size", "?") | |
| logging.info(f"[PPTX Load] Arquivo: {name} | mimeType: {mime} | size: {size}") | |
| if mime == "application/vnd.google-apps.presentation": | |
| # Google Slides native — export as PPTX | |
| logging.info("[PPTX Load] Google Slides detectado, exportando como PPTX...") | |
| raw = svc.files().export( | |
| fileId=file_id, | |
| mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation" | |
| ).execute() | |
| logging.info(f"[PPTX Load] Export concluido: {len(raw) if raw else 0} bytes") | |
| else: | |
| # Regular file — download binary | |
| logging.info("[PPTX Load] Baixando arquivo binario...") | |
| raw = svc.files().get_media(fileId=file_id).execute() | |
| logging.info(f"[PPTX Load] Download concluido: {len(raw) if raw else 0} bytes") | |
| if not isinstance(raw, bytes): | |
| raw = raw.encode("utf-8") if isinstance(raw, str) else raw | |
| # JSON (app-generated) — load directly | |
| if name.lower().endswith(".json") or mime == "application/json": | |
| data = json.loads(raw.decode("utf-8")) | |
| slides = data.get("slides", []) | |
| logging.info(f"[PPTX Load] JSON carregado: {len(slides)} slides") | |
| return slides | |
| # Try JSON first (might not have .json extension) | |
| try: | |
| data = json.loads(raw.decode("utf-8")) | |
| if "slides" in data: | |
| slides = data["slides"] | |
| logging.info(f"[PPTX Load] JSON detectado: {len(slides)} slides") | |
| return slides | |
| except (json.JSONDecodeError, UnicodeDecodeError): | |
| pass | |
| # PPTX binary — extract images and recreate HTML | |
| logging.info(f"[PPTX Load] Arquivo binario ({len(raw)} bytes), iniciando import PPTX...") | |
| result = _importar_pptx(raw) | |
| logging.info(f"[PPTX Load] Import retornou: {len(result) if result else 0} slides") | |
| return result | |
| except Exception as e: | |
| logging.error(f"[PPTX Load] Erro: {e}", exc_info=True) | |
| return None | |
| def _importar_pptx(pptx_bytes: bytes) -> list[dict] | None: | |
| """Extrai conteudo de cada slide do PPTX e recria como HTML via Claude. | |
| Tenta 2 abordagens por slide: | |
| 1. Se o slide tem imagem → envia imagem ao Claude (com retry) | |
| 2. Se o slide tem texto/formas → extrai texto e pede ao Claude recriar | |
| Nunca pula slides — garante que todos os N slides aparecem no resultado. | |
| """ | |
| import base64 | |
| from pptx import Presentation as PptxPresentation | |
| from modules.config import client_anthropic | |
| def _extrair_imagem_slide(slide_obj) -> bytes | None: | |
| """Extrai a maior imagem de um slide (qualquer tipo de shape com .image).""" | |
| imagens = [] | |
| for shape in slide_obj.shapes: | |
| try: | |
| # Tenta acessar .image em qualquer shape (nao apenas PICTURE) | |
| if hasattr(shape, "image") and shape.image and shape.image.blob: | |
| imagens.append(shape.image.blob) | |
| except Exception: | |
| pass | |
| if not imagens: | |
| return None | |
| # Retorna a maior imagem (provavelmente o slide full-bleed) | |
| return max(imagens, key=len) | |
| def _recriar_via_imagem(img_data: bytes, slide_num: int) -> str | None: | |
| """Envia imagem ao Claude para recriar como HTML. Retry com backoff.""" | |
| img_b64 = base64.b64encode(img_data).decode("utf-8") | |
| media_type = "image/png" if img_data[:4] == b'\x89PNG' else "image/jpeg" | |
| for tentativa in range(3): | |
| try: | |
| resp = client_anthropic.messages.create( | |
| model="claude-sonnet-4-6", | |
| max_tokens=4096, | |
| messages=[{ | |
| "role": "user", | |
| "content": [ | |
| { | |
| "type": "image", | |
| "source": {"type": "base64", "media_type": media_type, "data": img_b64}, | |
| }, | |
| { | |
| "type": "text", | |
| "text": ( | |
| "Recrie este slide EXATAMENTE como HTML/CSS. " | |
| "Mantenha cores, layout, fontes, espacamentos e todo o conteudo textual identico. " | |
| "Use as cores da marca Cora: pink #FE3E6D, dark #1A1A2E, dark2 #16213E. " | |
| "O HTML deve ter exatamente 1920x1080px com fundo completo. " | |
| "Retorne APENAS o HTML completo, sem explicacao, sem markdown, sem ```." | |
| ), | |
| }, | |
| ], | |
| }], | |
| timeout=120, | |
| ) | |
| html = _limpar_html_resposta(resp.content[0].text) | |
| if html and "<" in html: | |
| return html | |
| except Exception as e: | |
| logging.warning(f"[PPTX Import] Slide {slide_num} imagem tentativa {tentativa+1}/3: {e}") | |
| if tentativa < 2: | |
| time.sleep(2 ** tentativa) # 1s, 2s | |
| return None | |
| def _placeholder_html(slide_num: int, msg: str = "") -> str: | |
| """Gera HTML placeholder para slides que nao puderam ser recriados.""" | |
| return f"""<!DOCTYPE html><html><head><style> | |
| body {{ margin:0; width:1920px; height:1080px; display:flex; align-items:center; | |
| justify-content:center; background:linear-gradient(135deg, #1A1A2E, #16213E); | |
| font-family:system-ui,sans-serif; color:white; overflow:hidden; }} | |
| .box {{ text-align:center; }} | |
| h1 {{ font-size:48px; color:#FE3E6D; }} | |
| p {{ font-size:24px; opacity:0.7; }} | |
| </style></head><body><div class="box"> | |
| <h1>Slide {slide_num}</h1> | |
| <p>{msg or "Conteudo nao extraivel — edite este slide manualmente"}</p> | |
| </div></body></html>""" | |
| try: | |
| prs = PptxPresentation(io.BytesIO(pptx_bytes)) | |
| slides = [] | |
| total = len(prs.slides) | |
| logging.info(f"[PPTX Import] Iniciando import de {total} slides") | |
| for i, slide in enumerate(prs.slides): | |
| slide_num = i + 1 | |
| logging.info(f"[PPTX Import] Processando slide {slide_num}/{total}") | |
| # Strategy 1: Extract image (app-generated PPTX or Google Slides with images) | |
| img_data = _extrair_imagem_slide(slide) | |
| if img_data: | |
| html = _recriar_via_imagem(img_data, slide_num) | |
| if html: | |
| slides.append({"titulo": f"Slide {slide_num}", "html": html}) | |
| logging.info(f"[PPTX Import] Slide {slide_num} recriado via imagem") | |
| continue | |
| # Imagem existia mas falhou 3x — cria placeholder | |
| logging.warning(f"[PPTX Import] Slide {slide_num}: imagem falhou apos 3 tentativas") | |
| slides.append({"titulo": f"Slide {slide_num}", "html": _placeholder_html(slide_num, "Imagem detectada mas nao foi possivel recriar")}) | |
| continue | |
| # Strategy 2: Extract text content from shapes | |
| textos = [] | |
| for shape in slide.shapes: | |
| try: | |
| if shape.has_text_frame: | |
| for para in shape.text_frame.paragraphs: | |
| text = para.text.strip() | |
| if text: | |
| textos.append(text) | |
| if shape.has_table: | |
| table = shape.table | |
| rows = [] | |
| for row in table.rows: | |
| cells = [cell.text.strip() for cell in row.cells] | |
| rows.append(" | ".join(cells)) | |
| textos.append("TABELA:\n" + "\n".join(rows)) | |
| except Exception: | |
| pass | |
| if not textos: | |
| logging.warning(f"[PPTX Import] Slide {slide_num}: sem conteudo extraivel") | |
| slides.append({"titulo": f"Slide {slide_num}", "html": _placeholder_html(slide_num)}) | |
| continue | |
| conteudo = "\n".join(textos) | |
| logging.info(f"[PPTX Import] Slide {slide_num}: {len(textos)} blocos de texto extraidos") | |
| try: | |
| resp = client_anthropic.messages.create( | |
| model="claude-sonnet-4-6", | |
| max_tokens=4096, | |
| messages=[{ | |
| "role": "user", | |
| "content": ( | |
| f"Crie um slide HTML/CSS executivo e LINDO com o seguinte conteudo:\n\n" | |
| f"{conteudo}\n\n" | |
| f"REGRAS:\n" | |
| f"- Cores da marca Cora: pink #FE3E6D, dark #1A1A2E, dark2 #16213E, gray #F5F5F7\n" | |
| f"- Dimensoes exatas: 1920x1080px com fundo completo\n" | |
| f"- Layout profissional, executivo, visual rico\n" | |
| f"- Se houver TABELA, renderize como tabela HTML estilizada\n" | |
| f"- Mantenha TODO o conteudo textual\n" | |
| f"- Retorne APENAS o HTML completo, sem explicacao, sem markdown, sem ```." | |
| ), | |
| }], | |
| timeout=120, | |
| ) | |
| html = _limpar_html_resposta(resp.content[0].text) | |
| slides.append({"titulo": f"Slide {slide_num}", "html": html}) | |
| logging.info(f"[PPTX Import] Slide {slide_num} recriado via texto") | |
| except Exception as e: | |
| logging.error(f"[PPTX Import] Slide {slide_num} falhou: {e}") | |
| slides.append({"titulo": f"Slide {slide_num}", "html": _placeholder_html(slide_num, "Erro ao recriar slide")}) | |
| logging.info(f"[PPTX Import] Total: {len(slides)} slides importados de {total}") | |
| return slides if slides else None | |
| except Exception as e: | |
| logging.error(f"[PPTX Import] Erro geral: {e}", exc_info=True) | |
| return None | |
| def _limpar_html_resposta(text: str) -> str: | |
| """Remove markdown fences e whitespace de resposta do Claude.""" | |
| html = text.strip() | |
| if html.startswith("```"): | |
| html = re.sub(r'^```\w*\n?', '', html) | |
| html = re.sub(r'\n?```$', '', html) | |
| return html.strip() | |