mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-17 05:47:52 +00:00
549 lines
18 KiB
TypeScript
549 lines
18 KiB
TypeScript
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.
|
|
- O personagem principal deve ter um objetivo e uma jornada.
|
|
- O personagem principal deve ser um heroi que resolve um problema.
|
|
- O personagem principal deve ser um heroi que encontra um tesouro.
|
|
- O personagem principal deve ser um heroi que encontra um amigo.
|
|
- O personagem principal deve ser um heroi que encontra um inimigo.
|
|
- O personagem principal deve ser um heroi que encontra um mentor.
|
|
- Faça uma média de 50 a 70 palavras por página.
|
|
|
|
|
|
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` : ''}
|
|
`;
|
|
} |