From 9840fe76b0172cea3cda2c680d3f37cfad2155cf Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 1 Jan 2025 10:09:59 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20aprimora=20interface=20do=20exerc=C3=AD?= =?UTF-8?q?cio=20de=20forma=C3=A7=C3=A3o=20de=20palavras?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adiciona barra de progresso e feedback visual - Implementa lista de palavras encontradas - Melhora interatividade e estados visuais - Adiciona validação de palavras repetidas - Otimiza transições e animações - Mantém consistência com outros exercícios type: feat scope: exercises breaking: false --- CHANGELOG.md | 32 +++ PROJECT_CONTEXT.md | 51 ++++ .../exercises/PronunciationPractice.tsx | 185 ++++++++++++ .../exercises/SentenceCompletion.tsx | 178 ++++++++++++ src/components/exercises/WordFormation.tsx | 267 ++++++++++++++++++ .../learning/ExerciseSuggestions.tsx | 128 +++++++++ src/pages/student-dashboard/ExercisePage.tsx | 163 +++++++++++ src/pages/student-dashboard/StoryPage.tsx | 18 ++ src/routes.tsx | 5 + supabase/functions/generate-story/index.ts | 113 +++++++- 10 files changed, 1127 insertions(+), 13 deletions(-) create mode 100644 PROJECT_CONTEXT.md create mode 100644 src/components/exercises/PronunciationPractice.tsx create mode 100644 src/components/exercises/SentenceCompletion.tsx create mode 100644 src/components/exercises/WordFormation.tsx create mode 100644 src/components/learning/ExerciseSuggestions.tsx create mode 100644 src/pages/student-dashboard/ExercisePage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b381f..6e08bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,3 +48,35 @@ ### Modificado - Aprimorado fluxo de exclusão de histórias para garantir remoção completa de recursos - Adicionada confirmação visual durante processo de deleção + +## [1.4.0] - 2024-03-21 + +### Adicionado +- Implementado sistema de exercícios de alfabetização +- Adicionados três tipos de exercícios: formação de palavras, completar frases e treino de pronúncia +- Criada página dedicada para exercícios +- Implementado tracking de progresso nos exercícios + +### Técnico +- Criada nova rota para exercícios +- Adicionada tabela exercise_progress +- Implementada lógica de navegação entre exercícios + +## [Não Publicado] + +### Modificado +- Melhorada a interface do exercício de Formação de Palavras + - Adicionada barra de progresso com animação + - Adicionado feedback visual para acertos e erros + - Adicionada lista de palavras encontradas + - Melhorada a interatividade dos botões de sílabas + - Adicionados estados visuais para feedback do usuário + - Melhorada a área de exibição da palavra atual + - Adicionado contador de progresso + - Melhorada a consistência visual com outros exercícios + +### Técnico +- Refatorado componente WordFormation para melhor gerenciamento de estado +- Adicionada validação para palavras repetidas +- Implementado sistema de feedback temporário +- Otimizadas transições e animações diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md new file mode 100644 index 0000000..e7565c8 --- /dev/null +++ b/PROJECT_CONTEXT.md @@ -0,0 +1,51 @@ +# Story Generator - Plataforma Educacional de Leitura + +## Visão Geral +Plataforma educacional focada em crianças de 6-12 anos para prática e desenvolvimento de leitura, utilizando histórias geradas por IA e análise de áudio para feedback em tempo real. + +## Principais Funcionalidades +1. **Geração de Histórias** + - Histórias personalizadas por IA + - Adaptação ao nível do aluno + - Imagens ilustrativas geradas por IA + +2. **Sistema de Leitura** + - Gravação de áudio da leitura + - Análise de pronúncia e fluência + - Destaque de palavras importantes (WordHighlighter) + - Modal de detalhes para palavras difíceis + +3. **Análise de Performance** + - Métricas de leitura (fluência, pronúncia, etc.) + - Dashboard de progresso + - Histórico de gravações + - Conversão de áudio WebM para MP3 + +## Arquitetura + +### Frontend (React + TypeScript) +- `/src/components/learning/` - Componentes educacionais +- `/src/components/story/` - Componentes de história +- `/src/pages/student-dashboard/` - Dashboard do aluno +- `/src/utils/` - Utilitários (conversão de áudio, etc.) + +### Backend (Supabase) +- Functions: + - `process-audio` - Análise de áudio e feedback + - `generate-story` - Geração de histórias + +### Storage +- `recordings/` - Áudios das leituras +- `story-images/` - Imagens das histórias + +## Decisões Técnicas +1. Uso de Supabase para backend serverless +2. FFmpeg.js para conversão de áudio no cliente +3. Testes com Vitest e Testing Library +4. Tailwind CSS para estilização +5. Radix UI para componentes acessíveis + +## Estado Atual +- Implementado sistema de gravação e análise de áudio +- Desenvolvido componente WordHighlighter com testes +- Sistema de deleção de histórias com limpeza de recursos \ No newline at end of file diff --git a/src/components/exercises/PronunciationPractice.tsx b/src/components/exercises/PronunciationPractice.tsx new file mode 100644 index 0000000..c679549 --- /dev/null +++ b/src/components/exercises/PronunciationPractice.tsx @@ -0,0 +1,185 @@ +import React, { useState, useRef } from 'react'; +import { supabase } from '../../lib/supabase'; +import { Mic, Square, Play, Loader2, ArrowRight } from 'lucide-react'; + +interface PronunciationPracticeProps { + words: string[]; +} + +export function PronunciationPractice({ words }: PronunciationPracticeProps) { + const [currentWordIndex, setCurrentWordIndex] = useState(0); + const [isRecording, setIsRecording] = useState(false); + const [audioBlob, setAudioBlob] = useState(null); + const [score, setScore] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + const [completed, setCompleted] = useState(false); + + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + + // Verificar se há palavras para praticar + if (!words.length) { + return ( +
+

+ Não há palavras para praticar neste momento. +

+
+ ); + } + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + mediaRecorderRef.current = new MediaRecorder(stream); + chunksRef.current = []; + + mediaRecorderRef.current.ondataavailable = (e) => { + chunksRef.current.push(e.data); + }; + + mediaRecorderRef.current.onstop = () => { + const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' }); + setAudioBlob(audioBlob); + }; + + mediaRecorderRef.current.start(); + setIsRecording(true); + } catch (err) { + console.error('Erro ao iniciar gravação:', err); + } + }; + + const stopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop(); + setIsRecording(false); + mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop()); + } + }; + + const playAudio = () => { + if (audioBlob) { + const url = URL.createObjectURL(audioBlob); + const audio = new Audio(url); + audio.play(); + } + }; + + const handleNextWord = async () => { + setIsSubmitting(true); + try { + // Aqui você pode implementar a lógica de análise da pronúncia + // Por enquanto, vamos apenas avançar e dar pontos + setScore(prev => prev + 10); + + if (currentWordIndex < words.length - 1) { + setCurrentWordIndex(prev => prev + 1); + setAudioBlob(null); + } else { + setCompleted(true); + } + } catch (error) { + console.error('Erro ao processar áudio:', error); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Cabeçalho */} +
+

+ Treino de Pronúncia +

+
+ Palavra {currentWordIndex + 1} de {words.length} + {score} pontos +
+
+
+
+
+ + {completed ? ( +
+

+ Parabéns! Você completou o exercício! +

+

+ Pontuação final: {score} pontos +

+
+ ) : ( + <> + {/* Palavra atual */} +
+

+ {words[currentWordIndex]} +

+
+ + {/* Controles de gravação */} +
+ {!isRecording && !audioBlob && ( + + )} + + {isRecording && ( + + )} + + {audioBlob && ( + + )} +
+ + {/* Botão de próxima palavra */} + {audioBlob && ( + + )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/exercises/SentenceCompletion.tsx b/src/components/exercises/SentenceCompletion.tsx new file mode 100644 index 0000000..cabe50a --- /dev/null +++ b/src/components/exercises/SentenceCompletion.tsx @@ -0,0 +1,178 @@ +import React, { useState } from 'react'; +import { supabase } from '../../lib/supabase'; +import { Loader2 } from 'lucide-react'; + +interface SentenceCompletionProps { + story: { + id: string; + content: { + pages: Array<{ + text: string; + }>; + }; + }; +} + +export function SentenceCompletion({ story }: SentenceCompletionProps) { + const [currentSentence, setCurrentSentence] = useState(0); + const [userAnswer, setUserAnswer] = useState(''); + const [score, setScore] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + const [exerciseSentences, setExerciseSentences] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + + // Carregar palavras e preparar sentenças + React.useEffect(() => { + const loadExerciseWords = async () => { + try { + const { data: words, error } = await supabase + .from('story_exercise_words') + .select('*') + .eq('story_id', story.id) + .eq('exercise_type', 'completion'); + + if (error) throw error; + + // Extrair todas as sentenças do texto + const allSentences = story.content.pages + .map(page => page.text.split(/[.!?]+/)) + .flat() + .filter(Boolean) + .map(sentence => sentence.trim()); + + // Preparar exercícios com as palavras do banco + const exercises = allSentences + .filter(sentence => + words?.some(word => + sentence.toLowerCase().includes(word.word.toLowerCase()) + ) + ) + .map(sentence => { + const word = words?.find(w => + sentence.toLowerCase().includes(w.word.toLowerCase()) + ); + return { + sentence: sentence.replace( + new RegExp(word?.word || '', 'i'), + '_____' + ), + answer: word?.word || '' + }; + }) + .filter(exercise => exercise.answer); // Remover exercícios sem resposta + + setExerciseSentences(exercises); + setIsLoading(false); + } catch (error) { + console.error('Erro ao carregar palavras:', error); + setIsLoading(false); + } + }; + + loadExerciseWords(); + }, [story.id, story.content.pages]); + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!exerciseSentences.length) { + return ( +
+

+ Não foi possível gerar exercícios para este texto. +

+
+ ); + } + + const currentExercise = exerciseSentences[currentSentence]; + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + const isCorrect = userAnswer.toLowerCase() === currentExercise.answer.toLowerCase(); + + if (isCorrect) { + setScore(prev => prev + 10); + } + + // Avançar para próxima sentença + if (currentSentence < exerciseSentences.length - 1) { + setCurrentSentence(prev => prev + 1); + setUserAnswer(''); + } + + } catch (error) { + console.error('Erro ao verificar resposta:', error); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+

+ Complete a Frase +

+ + {/* Progresso */} +
+
+ Questão {currentSentence + 1} de {exerciseSentences.length} + {score} pontos +
+
+
+
+
+ + {/* Sentença atual */} +
+

+ {currentExercise.sentence} +

+
+ + {/* Campo de resposta */} +
+ setUserAnswer(e.target.value)} + placeholder="Digite a palavra que completa a frase..." + className="w-full px-4 py-3 text-lg border border-gray-300 rounded-lg + focus:outline-none focus:ring-2 focus:ring-purple-500" + /> +
+ + {/* Botão de verificação */} + +
+ ); +} \ No newline at end of file diff --git a/src/components/exercises/WordFormation.tsx b/src/components/exercises/WordFormation.tsx new file mode 100644 index 0000000..30364e7 --- /dev/null +++ b/src/components/exercises/WordFormation.tsx @@ -0,0 +1,267 @@ +import React, { useState, useEffect } from 'react'; +import { supabase } from '../../lib/supabase'; + +interface WordFormationProps { + words: string[]; + storyId: string; +} + +interface SyllableWord { + word: string; + syllables: string[]; +} + +export function WordFormation({ words, storyId }: WordFormationProps) { + const [availableSyllables, setAvailableSyllables] = useState([]); + const [userWord, setUserWord] = useState(''); + const [score, setScore] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [targetWords, setTargetWords] = useState([]); + const [completedWords, setCompletedWords] = useState([]); + const [showFeedback, setShowFeedback] = useState<{ + type: 'success' | 'error'; + message: string; + } | null>(null); + + useEffect(() => { + const loadWords = async () => { + try { + const { data: exerciseWords, error } = await supabase + .from('story_exercise_words') + .select('*') + .eq('story_id', storyId) + .eq('exercise_type', 'formation'); + + if (error) throw error; + + // Dividir palavras em sílabas (simplificado) + const wordList = exerciseWords?.map(w => ({ + word: w.word, + syllables: dividePalavraEmSilabas(w.word) + })) || []; + + // Coletar todas as sílabas únicas + const allSyllables = wordList.flatMap(w => w.syllables); + const uniqueSyllables = [...new Set(allSyllables)]; + + setTargetWords(wordList); + setAvailableSyllables(uniqueSyllables); + setIsLoading(false); + } catch (error) { + console.error('Erro ao carregar palavras:', error); + setIsLoading(false); + } + }; + + loadWords(); + }, [storyId]); + + const dividePalavraEmSilabas = (palavra: string): string[] => { + // Implementação simplificada - você pode usar uma biblioteca mais robusta + // ou implementar regras mais complexas de divisão silábica + const silabas: string[] = []; + let silaba = ''; + + for (let i = 0; i < palavra.length; i++) { + const letra = palavra[i]; + silaba += letra; + + // Regras básicas de divisão silábica + if (i < palavra.length - 1) { + const proximaLetra = palavra[i + 1]; + + // Se a próxima letra for uma vogal e a atual não + if (isVogal(proximaLetra) && !isVogal(letra)) { + silabas.push(silaba); + silaba = ''; + } + // Se a atual for uma vogal e a próxima uma consoante + else if (isVogal(letra) && !isVogal(proximaLetra)) { + silabas.push(silaba); + silaba = ''; + } + } + } + + if (silaba) { + silabas.push(silaba); + } + + return silabas; + }; + + const isVogal = (letra: string): boolean => { + return /[aeiouáéíóúâêîôûãõàèìòùäëïöü]/i.test(letra); + }; + + const handleSyllableClick = (syllable: string) => { + setUserWord(prev => prev + syllable); + }; + + const handleVerify = () => { + const matchedWord = targetWords.find( + w => w.word.toLowerCase() === userWord.toLowerCase() + ); + + if (matchedWord && !completedWords.includes(matchedWord.word)) { + setScore(prev => prev + 10); + setCompletedWords(prev => [...prev, matchedWord.word]); + setShowFeedback({ + type: 'success', + message: 'Parabéns! Palavra correta!' + }); + } else if (completedWords.includes(matchedWord?.word || '')) { + setShowFeedback({ + type: 'error', + message: 'Você já encontrou esta palavra!' + }); + } else { + setShowFeedback({ + type: 'error', + message: 'Tente novamente!' + }); + } + + setTimeout(() => { + setShowFeedback(null); + }, 2000); + + setUserWord(''); + }; + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!availableSyllables.length) { + return ( +
+

+ Não há palavras para formar neste momento. +

+
+ ); + } + + return ( +
+

+ Formação de Palavras +

+ + {/* Barra de Progresso */} +
+
+ + Palavras encontradas: {completedWords.length} de {targetWords.length} + + {score} pontos +
+
+
+
+
+ + {/* Feedback Visual */} + {showFeedback && ( +
+ {showFeedback.message} +
+ )} + + {/* Sílabas Disponíveis */} +
+

+ Sílabas Disponíveis: +

+
+ {availableSyllables.map((syllable, index) => ( + + ))} +
+
+ + {/* Palavra do Usuário */} +
+

+ Sua Palavra: +

+
+ {userWord || ( + + Clique nas sílabas para formar uma palavra + + )} +
+
+ + {/* Palavras Encontradas */} +
+

+ Palavras Encontradas: +

+
+ {completedWords.map((word, index) => ( + + {word} + + ))} +
+
+ + {/* Botões de Ação */} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/learning/ExerciseSuggestions.tsx b/src/components/learning/ExerciseSuggestions.tsx new file mode 100644 index 0000000..cb59511 --- /dev/null +++ b/src/components/learning/ExerciseSuggestions.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from 'react'; +import { BookOpen, Puzzle, Mic } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { supabase } from '../../lib/supabase'; + +interface ExerciseSuggestionsProps { + storyId: string; + storyText: string; + readingMetrics: { + difficultWords: string[]; + errorCount: number; + pauseCount: number; + fluencyScore: number; + }; +} + +interface ExerciseWord { + word: string; + exercise_type: string; + phonemes: string[] | null; + syllable_pattern: string | null; +} + +export function ExerciseSuggestions({ storyId, storyText, readingMetrics }: ExerciseSuggestionsProps) { + const navigate = useNavigate(); + const [exerciseWords, setExerciseWords] = useState([]); + + useEffect(() => { + const loadExerciseWords = async () => { + const { data, error } = await supabase + .from('story_exercise_words') + .select('*') + .eq('story_id', storyId) + .order('created_at', { ascending: true }); + + if (!error && data) { + setExerciseWords(data); + } + }; + + loadExerciseWords(); + }, [storyId]); + + const handleExerciseSelect = (exerciseType: string) => { + if (!storyId) { + console.error('ID da história não fornecido'); + return; + } + navigate(`/aluno/historias/${storyId}/exercicios/${exerciseType}`); + }; + + const generateExercises = () => { + const exercises = [ + { + type: 'word-formation', + title: 'Formação de Palavras', + description: 'Monte novas palavras usando sílabas da história', + icon: , + words: exerciseWords + .filter(w => w.exercise_type === 'formation') + .map(w => w.word), + }, + { + type: 'sentence-completion', + title: 'Complete a História', + description: 'Complete as frases com as palavras corretas', + icon: , + words: exerciseWords + .filter(w => w.exercise_type === 'completion') + .map(w => w.word), + }, + { + type: 'pronunciation-practice', + title: 'Treino de Pronúncia', + description: 'Pratique a pronúncia das palavras difíceis', + icon: , + words: exerciseWords + .filter(w => w.exercise_type === 'pronunciation') + .map(w => w.word), + } + ]; + + return exercises; + }; + + return ( +
+

+ Exercícios Sugeridos +

+
+ {generateExercises().map((exercise) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/student-dashboard/ExercisePage.tsx b/src/pages/student-dashboard/ExercisePage.tsx new file mode 100644 index 0000000..c45a514 --- /dev/null +++ b/src/pages/student-dashboard/ExercisePage.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { supabase } from '../../lib/supabase'; +import { WordFormation } from '../../components/exercises/WordFormation'; +import { SentenceCompletion } from '../../components/exercises/SentenceCompletion'; +import { PronunciationPractice } from '../../components/exercises/PronunciationPractice'; +import { ArrowLeft } from 'lucide-react'; + +interface ExerciseWord { + word: string; + exercise_type: string; + phonemes: string[] | null; + syllable_pattern: string | null; +} + +export function ExercisePage() { + const { id: storyId, type } = useParams(); + const navigate = useNavigate(); + const [exerciseData, setExerciseData] = React.useState(null); + const [exerciseWords, setExerciseWords] = React.useState([]); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + const loadExerciseData = async () => { + try { + // Adicionar log para debug + console.log('Carregando exercício:', { storyId, type }); + + // Buscar história + const { data: story, error: storyError } = await supabase + .from('stories') + .select(` + *, + story_recordings ( + id, + improvements, + created_at + ) + `) + .eq('id', storyId) + .single(); + + if (storyError) throw storyError; + + // Mapear tipo do exercício para o valor correto no banco + const exerciseType = type === 'pronunciation-practice' + ? 'pronunciation' + : type === 'word-formation' + ? 'formation' + : 'completion'; + + // Buscar palavras do exercício + const { data: words, error: wordsError } = await supabase + .from('story_exercise_words') + .select('*') + .eq('story_id', storyId) + .eq('exercise_type', exerciseType) // Usando o tipo mapeado + .order('created_at', { ascending: true }); + + // Adicionar log para debug + console.log('Palavras encontradas:', words); + + if (wordsError) throw wordsError; + + if (!story) { + throw new Error('História não encontrada'); + } + + // Ordenar gravações por data e pegar a mais recente + const latestRecording = story.story_recordings + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]; + + setExerciseData({ + story, + metrics: latestRecording, + }); + + setExerciseWords(words || []); + + } catch (err) { + console.error('Erro ao carregar dados:', err); + setError('Não foi possível carregar os dados do exercício'); + } + }; + + loadExerciseData(); + }, [storyId, type]); + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (!exerciseData) { + return ( +
+
+
+
+
+
+ ); + } + + const renderExercise = () => { + if (!exerciseData?.story) { + return
Carregando...
; + } + + switch (type) { + case 'word-formation': + return ( + w.word)} + storyId={storyId as string} + /> + ); + case 'sentence-completion': + return ( + + ); + case 'pronunciation-practice': + return ( + w.word)} + /> + ); + default: + return
Exercício não encontrado
; + } + }; + + return ( +
+ {/* Cabeçalho */} +
+ +
+ + {/* Exercício */} + {renderExercise()} +
+ ); +} \ No newline at end of file diff --git a/src/pages/student-dashboard/StoryPage.tsx b/src/pages/student-dashboard/StoryPage.tsx index 2233933..5f90610 100644 --- a/src/pages/student-dashboard/StoryPage.tsx +++ b/src/pages/student-dashboard/StoryPage.tsx @@ -9,6 +9,7 @@ import type { MetricsData } from '../../components/story/StoryMetrics'; import { getOptimizedImageUrl } from '../../lib/imageUtils'; import { convertWebmToMp3 } from '../../utils/audioConverter'; import * as Dialog from '@radix-ui/react-dialog'; +import { ExerciseSuggestions } from '../../components/learning/ExerciseSuggestions'; interface StoryRecording { id: string; @@ -775,6 +776,23 @@ export function StoryPage() {
+ + {/* Depois do StoryMetrics */} + {recordings.length > 0 && ( + imp.includes('palavra')) + .map(imp => imp.match(/palavra "([^"]+)"/)?.[1]) + .filter(Boolean) as string[], + errorCount: getLatestRecording().error_count, + pauseCount: getLatestRecording().pause_count, + fluencyScore: getLatestRecording().fluency_score + }} + /> + )}
); } \ No newline at end of file diff --git a/src/routes.tsx b/src/routes.tsx index 8df35a3..f9336ba 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -29,6 +29,7 @@ import { DemoPage } from './pages/demo/DemoPage'; import { ParentsLandingPage } from './pages/landing/ParentsLandingPage'; import { EducationalForParents } from './pages/landing/EducationalForParents'; import { TestWordHighlighter } from './pages/TestWordHighlighter'; +import { ExercisePage } from './pages/student-dashboard/ExercisePage'; export const router = createBrowserRouter([ { @@ -197,5 +198,9 @@ export const router = createBrowserRouter([ { path: '/para-educadores', element: , + }, + { + path: '/aluno/historias/:id/exercicios/:type', + element: } ]); \ No newline at end of file diff --git a/supabase/functions/generate-story/index.ts b/supabase/functions/generate-story/index.ts index dff3206..7c85970 100644 --- a/supabase/functions/generate-story/index.ts +++ b/supabase/functions/generate-story/index.ts @@ -12,6 +12,7 @@ interface StoryPrompt { character_id: string; setting_id: string; context?: string; + difficulty: 'easy' | 'medium' | 'hard'; } const ALLOWED_ORIGINS = [ @@ -29,6 +30,28 @@ function getOptimizedImageUrl(originalUrl: string, width = 800): string { return originalUrl; } +interface StoryResponse { + title: string; + content: { + pages: Array<{ + text: 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 = { @@ -106,15 +129,46 @@ serve(async (req) => { - Ambientado em ${setting.title} - Personagem principal baseado em ${character.title} + Requisitos específicos para exercícios: + 1. Para o exercício de completar frases: + - Selecione 5-8 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: { "title": "Título da História", - "pages": [ - { - "text": "Texto da página", - "image_prompt": "Descrição para gerar a imagem" + "content": { + "pages": [ + { + "text": "Texto da página com frases completas...", + "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"] } - ] + } } ` console.log('[GPT] Prompt construído:', prompt) @@ -137,25 +191,25 @@ serve(async (req) => { }) console.log('[GPT] História gerada:', completion.choices[0].message) - const storyContent = JSON.parse(completion.choices[0].message.content || '{}') + const storyContent = JSON.parse(completion.choices[0].message.content || '{}') as StoryResponse; // Validar estrutura do retorno da IA - if (!storyContent.title || !Array.isArray(storyContent.pages)) { + 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.pages.map(async (page: any, index: number) => { - console.log(`[DALL-E] Gerando imagem ${index + 1}/${storyContent.pages.length}...`) + 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.image_prompt}. Style: children's book illustration, colorful, educational, safe for kids.`, + 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", - style: "natural" + model: "dall-e-3" }) // Baixar a imagem do URL do DALL-E @@ -244,7 +298,7 @@ serve(async (req) => { story_id: record.id, original_prompt: prompt, ai_response: completion.choices[0].message.content, - model_used: 'gpt-4-turbo' + model_used: 'gpt-4o-mini' }); if (genError) throw new Error(`Erro ao salvar metadados: ${genError.message}`); @@ -261,6 +315,39 @@ serve(async (req) => { 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: record.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); + + if (wordsError) { + console.error('Erro ao salvar palavras dos exercícios:', wordsError); + } + } + } + return new Response( JSON.stringify({ success: true,