import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import OpenAI from 'https://esm.sh/openai@4.20.1' const openai = new OpenAI({ apiKey: Deno.env.get('OPENAI_API_KEY') }); interface StoryPrompt { theme_id: string; subject_id: string; character_id: string; setting_id: string; context?: string; language_type: string; difficulty: 'easy' | 'medium' | 'hard'; } interface StoryPayload { story_id: string; student_id: string; theme_id: string; subject_id: string; character_id: string; setting_id: string; language_type: string; theme?: string; subject?: string; character?: string; setting?: string; context?: string; voice_context?: string; } const ALLOWED_ORIGINS = [ 'http://localhost:5173', // Vite dev server 'http://localhost:3000', // Caso use outro port 'https://leiturama.ai', // Produção 'https://leiturama.netlify.app' ]; interface StoryResponse { title: string; content: { pages: Array<{ text: string; text_syllables: string; imagePrompt: string; keywords?: string[]; phonemes?: string[]; syllablePatterns?: string[]; }>; }; metadata?: { targetAge?: number; difficulty?: string; exerciseWords?: { pronunciation?: string[]; formation?: string[]; completion?: string[]; }; }; } serve(async (req) => { const origin = req.headers.get('origin') || ''; const corsHeaders = { 'Cross-Origin-Resource-Policy': 'cross-origin', 'Access-Control-Allow-Origin': ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0], 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, PUT, DELETE', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Max-Age': '86400', // 24 horas 'Cross-Origin-Embedder-Policy': 'credentialless' }; // Preflight request if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }) } const payload = await req.json() as StoryPayload; console.log('[Request] Payload recebido:', payload); try { const supabase = createClient( Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' ); console.log('[Supabase] Cliente inicializado'); if (!payload.story_id) { throw new Error('ID da história não fornecido'); } console.log('[DB] Buscando categorias...'); const [themeResult, subjectResult, characterResult, settingResult] = await Promise.all([ supabase.from('story_themes').select('*').eq('id', payload.theme_id).single(), supabase.from('story_subjects').select('*').eq('id', payload.subject_id).single(), supabase.from('story_characters').select('*').eq('id', payload.character_id).single(), supabase.from('story_settings').select('*').eq('id', payload.setting_id).single() ]); console.log('[DB] Resultados das consultas:', { theme: themeResult, subject: subjectResult, character: characterResult, setting: settingResult }); if (themeResult.error) throw new Error(`Erro ao buscar tema: ${themeResult.error.message}`); if (subjectResult.error) throw new Error(`Erro ao buscar disciplina: ${subjectResult.error.message}`); if (characterResult.error) throw new Error(`Erro ao buscar personagem: ${characterResult.error.message}`); if (settingResult.error) throw new Error(`Erro ao buscar cenário: ${settingResult.error.message}`); if (!themeResult.data) throw new Error(`Tema não encontrado: ${payload.theme_id}`); if (!subjectResult.data) throw new Error(`Disciplina não encontrada: ${payload.subject_id}`); if (!characterResult.data) throw new Error(`Personagem não encontrado: ${payload.character_id}`); if (!settingResult.data) throw new Error(`Cenário não encontrado: ${payload.setting_id}`); const theme = themeResult.data; const subject = subjectResult.data; const character = characterResult.data; const setting = settingResult.data; console.log('[Validation] Categorias validadas com sucesso'); console.log('[GPT] Construindo prompt...'); const prompt = buildPrompt(payload, payload.voice_context); console.log('[GPT] Prompt construído:', prompt); console.log('[GPT] Iniciando geração da história...'); const completion = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [ { role: "system", content: "Você é um contador de histórias infantis especializado em conteúdo educativo." }, { role: "user", content: prompt } ], store: true, response_format: { type: "json_schema", json_schema: { name: "story", schema: { type: "object", properties: { title: { type: "string", description: "Título da história" }, content: { type: "object", properties: { pages: { type: "array", items: { type: "object", properties: { text: { type: "string", description: "Texto completo da página" }, text_syllables: { type: "string", description: "Texto separado em sílabas" }, imagePrompt: { type: "string", description: "Descrição para gerar imagem" }, keywords: { type: "array", items: { type: "string" } }, phonemes: { type: "array", items: { type: "string" } }, syllablePatterns: { type: "array", items: { type: "string", enum: ["CV", "CVC", "CCVC"] } } }, required: ["text", "text_syllables", "imagePrompt", "keywords", "phonemes", "syllablePatterns"], additionalProperties: false } } }, required: ["pages"], additionalProperties: false }, metadata: { type: "object", properties: { targetAge: { type: "number" }, difficulty: { type: "string" }, exerciseWords: { type: "object", properties: { pronunciation: { type: "array", items: { type: "string" } }, formation: { type: "array", items: { type: "string" } }, completion: { type: "array", items: { type: "string" } } }, required: ["pronunciation", "formation", "completion"], additionalProperties: false } }, required: ["targetAge", "difficulty", "exerciseWords"], additionalProperties: false } }, required: ["title", "content", "metadata"], additionalProperties: false }, strict: true } }, temperature: 0.7 }); if (completion.choices[0].finish_reason === "length") { throw new Error("Resposta incompleta do modelo"); } const story_response = completion.choices[0].message; if (story_response.refusal) { console.log(story_response.refusal); throw new Error("História recusada pelo modelo"); } else if (!story_response.content) { throw new Error("Sem conteúdo na resposta"); } console.log('[GPT] História gerada:', completion.choices[0].message); const storyContent = JSON.parse(completion.choices[0].message.content || '{}') as StoryResponse; // Validar estrutura do retorno da IA if (!storyContent.title || !storyContent.content?.pages?.length) { console.error('Resposta da IA:', storyContent); throw new Error('Formato inválido retornado pela IA'); } console.log('[DALL-E] Iniciando geração de imagens...'); const pages = await Promise.all( storyContent.content.pages.map(async (page, index) => { console.log(`[DALL-E] Gerando imagem ${index + 1}/${storyContent.content.pages.length}...`); // Gerar imagem com DALL-E const imageResponse = await openai.images.generate({ prompt: `${page.imagePrompt}. For Kids. Educational Purpose. Style inpired by DreamWorks and Pixar. Provide a high quality image. Provide a ethnic diversity.`, n: 1, size: "1024x1024", model: "dall-e-3" }); // Baixar a imagem do URL do DALL-E console.log(`[Storage] Baixando imagem ${index + 1}...`); const imageUrl = imageResponse.data[0].url; const imageRes = await fetch(imageUrl); const imageBuffer = await imageRes.arrayBuffer(); // Gerar nome único para o arquivo const fileName = `${payload.story_id}/page-${index + 1}-${Date.now()}.png`; // Salvar no Storage do Supabase console.log(`[Storage] Salvando imagem ${index + 1} no bucket...`); const { error: storageError } = await supabase .storage .from('story-images') .upload(fileName, imageBuffer, { contentType: 'image/png', cacheControl: '3600', upsert: false }); if (storageError) { throw new Error(`Erro ao salvar imagem ${index + 1} no storage: ${storageError.message}`); } // Gerar URL público da imagem sem transformações const { data: publicUrl } = supabase .storage .from('story-images') .getPublicUrl(fileName); console.log(`[Storage] Imagem ${index + 1} salva com sucesso`); return { text: page.text, image: publicUrl.publicUrl, // Salvar apenas o caminho do arquivo image_path: fileName }; }) ); console.log('[DALL-E] Todas as imagens geradas com sucesso'); // Preparar e salvar os dados const { error: storyError } = await supabase .from('stories') .update({ title: storyContent.title, status: 'published', theme_id: theme.id, subject_id: subject.id, character_id: character.id, setting_id: setting.id, context: payload.voice_context || payload.context || '', updated_at: new Date().toISOString() }) .eq('id', payload.story_id) .select() .single(); if (storyError) throw new Error(`Erro ao atualizar história: ${storyError.message}`); // Salvar páginas const { error: pagesError } = await supabase .from('story_pages') .insert( pages.map((page, index) => ({ story_id: payload.story_id, page_number: index + 1, text: page.text, text_syllables: page.text_syllables, image_url: page.image, image_path: page.image_path })) ); if (pagesError) throw new Error(`Erro ao salvar páginas: ${pagesError.message}`); // Salvar metadados da geração const { error: genError } = await supabase .from('story_generations') .insert({ story_id: payload.story_id, original_prompt: prompt, ai_response: completion.choices[0].message.content, model_used: 'gpt-4o-mini' }); if (genError) throw new Error(`Erro ao salvar metadados: ${genError.message}`); // Buscar história completa com relacionamentos const { data: fullStory, error: fetchError } = await supabase .from('stories') .select(` *, pages:story_pages(*) `) .eq('id', payload.story_id) .single(); if (fetchError) throw new Error(`Erro ao buscar história completa: ${fetchError.message}`); // Salvar palavras dos exercícios se existirem if (storyContent.metadata?.exerciseWords) { const exerciseWords = Object.entries(storyContent.metadata.exerciseWords) .flatMap(([type, words]) => (words || []).map(word => { const pageWithWord = storyContent.content.pages .find(p => p.text.toLowerCase().includes(word.toLowerCase())); // Só incluir palavras que realmente existem no texto if (!pageWithWord) return null; return { story_id: payload.story_id, word, exercise_type: type, phonemes: pageWithWord?.phonemes || null, syllable_pattern: pageWithWord?.syllablePatterns?.[0] || null }; }) ) .filter(Boolean); // Remove null values if (exerciseWords.length > 0) { const { error: wordsError } = await supabase .from('story_exercise_words') .insert(exerciseWords.map(word => ({ ...word, story_id: payload.story_id }))); if (wordsError) { console.error('Erro ao salvar palavras dos exercícios:', wordsError); } } } return new Response( JSON.stringify({ success: true, message: 'História gerada e salva com sucesso', story: fullStory }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } catch (error) { return new Response( JSON.stringify({ error: error.message, stack: error.stack, timestamp: new Date().toISOString() }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 } ); } }); function buildPrompt(base: StoryPrompt, voice?: string) { const languageInstructions = { 'pt-BR': { language: 'português do Brasil', instructions: 'Use linguagem apropriada para crianças brasileiras' }, 'en-US': { language: 'English (US)', instructions: 'Use language appropriate for American children' }, 'es-ES': { language: 'español de España', instructions: 'Usa un lenguaje apropiado para niños españoles' } }; const selectedLanguage = languageInstructions[base.language_type as keyof typeof languageInstructions] || languageInstructions['pt-BR']; return ` Crie uma história educativa para crianças com as seguintes características: Tema: ${base.theme_id} Disciplina: ${base.subject_id} Personagem Principal: ${base.character_id} Cenário: ${base.setting_id} Idioma: ${selectedLanguage.language} ${base.context ? `Contexto Adicional: ${base.context}` : ''} Requisitos: - História adequada para crianças de 6-12 anos - Conteúdo educativo focado em ${base.subject_id} - ${selectedLanguage.instructions} - Linguagem clara e envolvente - 3-8 páginas de conteúdo - Cada página deve ter um texto curto, o mesmo texto separado em sílabas e uma sugestão para uma imagem - Evitar conteúdo sensível ou inadequado - Incluir elementos de ${base.theme_id} - Ambientado em ${base.setting_id} - Personagem principal baseado em ${base.character_id} - Use a jornada no héroi para escrever as histórias. Requisitos específicos para exercícios: 1. Para o exercício de completar frases: - Selecione 8-12 palavras importantes do texto - Escolha palavras que sejam substantivos, verbos ou adjetivos - Evite artigos, preposições ou palavras muito simples - As palavras devem ser relevantes para o contexto da história 2. Para o exercício de formação de palavras: - Selecione palavras com diferentes padrões silábicos - Inclua palavras que possam ser divididas em sílabas - Priorize palavras com 2-4 sílabas 3. Para o exercício de pronúncia: - Selecione palavras que trabalhem diferentes fonemas - Inclua palavras com encontros consonantais - Priorize palavras que sejam desafiadoras para a faixa etária Formato da resposta em JSON: { "title": "Título da História", "content": { "pages": [ { "text": "Texto da página com frases completas...", "text_syllables": "Texto da página com frases completas separadas em sílabas em ${selectedLanguage.language}...", "imagePrompt": "Descrição para gerar imagem...", "keywords": ["palavra1", "palavra2"], "phonemes": ["fonema1", "fonema2"], "syllablePatterns": ["CV", "CVC", "CCVC"] } ] }, "metadata": { "targetAge": number, "difficulty": string, "exerciseWords": { "pronunciation": ["palavra1", "palavra2"], "formation": ["palavra3", "palavra4"], "completion": ["palavra5", "palavra6"] } } } ${voice ? ` Contexto Adicional por Voz: "${voice}" Diretrizes Adicionais: - Priorizar elementos mencionados na descrição oral - Manter tom e estilo consistentes com a gravação - Incluir palavras-chave identificadas` : ''} `; }