feat: aprimora interface do exercício de formação de palavras

- 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
This commit is contained in:
Lucas Santana 2025-01-01 10:09:59 -03:00
parent 745f8de40e
commit 9840fe76b0
10 changed files with 1127 additions and 13 deletions

View File

@ -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

51
PROJECT_CONTEXT.md Normal file
View File

@ -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

View File

@ -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<Blob | null>(null);
const [score, setScore] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [completed, setCompleted] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
// Verificar se há palavras para praticar
if (!words.length) {
return (
<div className="max-w-2xl mx-auto p-6">
<p className="text-gray-600">
Não palavras para praticar neste momento.
</p>
</div>
);
}
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 (
<div className="max-w-2xl mx-auto p-6">
{/* Cabeçalho */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900">
Treino de Pronúncia
</h2>
<div className="mt-2 flex justify-between items-center text-sm text-gray-500">
<span>Palavra {currentWordIndex + 1} de {words.length}</span>
<span>{score} pontos</span>
</div>
<div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 transition-all"
style={{ width: `${((currentWordIndex + 1) / words.length) * 100}%` }}
/>
</div>
</div>
{completed ? (
<div className="text-center">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
Parabéns! Você completou o exercício!
</h3>
<p className="text-gray-600">
Pontuação final: {score} pontos
</p>
</div>
) : (
<>
{/* Palavra atual */}
<div className="mb-8 text-center">
<h3 className="text-4xl font-bold text-gray-900">
{words[currentWordIndex]}
</h3>
</div>
{/* Controles de gravação */}
<div className="flex justify-center gap-4 mb-8">
{!isRecording && !audioBlob && (
<button
onClick={startRecording}
className="flex items-center gap-2 px-6 py-3 bg-red-600 text-white rounded-lg
hover:bg-red-700 transition-colors"
>
<Mic className="w-5 h-5" />
Gravar
</button>
)}
{isRecording && (
<button
onClick={stopRecording}
className="flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg
hover:bg-gray-700 transition-colors"
>
<Square className="w-5 h-5" />
Parar
</button>
)}
{audioBlob && (
<button
onClick={playAudio}
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg
hover:bg-purple-700 transition-colors"
>
<Play className="w-5 h-5" />
Ouvir
</button>
)}
</div>
{/* Botão de próxima palavra */}
{audioBlob && (
<button
onClick={handleNextWord}
disabled={isSubmitting}
className="w-full flex items-center justify-center gap-2 py-3 bg-purple-600 text-white
rounded-lg font-medium hover:bg-purple-700 disabled:bg-gray-300
disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
{currentWordIndex < words.length - 1 ? 'Próxima Palavra' : 'Finalizar'}
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
)}
</>
)}
</div>
);
}

View File

@ -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<Array<{
sentence: string;
answer: string;
}>>([]);
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 (
<div className="max-w-2xl mx-auto p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
<div className="h-64 bg-gray-200 rounded" />
</div>
</div>
);
}
if (!exerciseSentences.length) {
return (
<div className="max-w-2xl mx-auto p-6">
<p className="text-gray-600">
Não foi possível gerar exercícios para este texto.
</p>
</div>
);
}
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 (
<div className="max-w-2xl mx-auto p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Complete a Frase
</h2>
{/* Progresso */}
<div className="mb-6">
<div className="flex justify-between text-sm text-gray-500">
<span>Questão {currentSentence + 1} de {exerciseSentences.length}</span>
<span>{score} pontos</span>
</div>
<div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 transition-all"
style={{ width: `${((currentSentence + 1) / exerciseSentences.length) * 100}%` }}
/>
</div>
</div>
{/* Sentença atual */}
<div className="mb-8">
<p className="text-xl leading-relaxed text-gray-700">
{currentExercise.sentence}
</p>
</div>
{/* Campo de resposta */}
<div className="mb-8">
<input
type="text"
value={userAnswer}
onChange={(e) => 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"
/>
</div>
{/* Botão de verificação */}
<button
onClick={handleSubmit}
disabled={!userAnswer || isSubmitting}
className="w-full py-3 bg-purple-600 text-white rounded-lg font-medium
hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed
transition-colors"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
) : (
'Verificar'
)}
</button>
</div>
);
}

View File

@ -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<string[]>([]);
const [userWord, setUserWord] = useState<string>('');
const [score, setScore] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [targetWords, setTargetWords] = useState<SyllableWord[]>([]);
const [completedWords, setCompletedWords] = useState<string[]>([]);
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 (
<div className="max-w-2xl mx-auto p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
<div className="h-64 bg-gray-200 rounded" />
</div>
</div>
);
}
if (!availableSyllables.length) {
return (
<div className="max-w-2xl mx-auto p-6">
<p className="text-gray-600">
Não palavras para formar neste momento.
</p>
</div>
);
}
return (
<div className="max-w-2xl mx-auto p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Formação de Palavras
</h2>
{/* Barra de Progresso */}
<div className="mb-8">
<div className="flex justify-between text-sm text-gray-500 mb-2">
<span>
Palavras encontradas: {completedWords.length} de {targetWords.length}
</span>
<span>{score} pontos</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 transition-all duration-500"
style={{
width: `${(completedWords.length / targetWords.length) * 100}%`
}}
/>
</div>
</div>
{/* Feedback Visual */}
{showFeedback && (
<div className={`mb-4 p-4 rounded-lg text-center font-medium
${showFeedback.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{showFeedback.message}
</div>
)}
{/* Sílabas Disponíveis */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Sílabas Disponíveis:
</h3>
<div className="flex flex-wrap gap-2">
{availableSyllables.map((syllable, index) => (
<button
key={index}
onClick={() => handleSyllableClick(syllable)}
className="px-4 py-2 bg-purple-100 rounded-lg
hover:bg-purple-200 active:bg-purple-300
transition-all transform hover:scale-105
text-purple-900 font-medium shadow-sm"
>
{syllable}
</button>
))}
</div>
</div>
{/* Palavra do Usuário */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Sua Palavra:
</h3>
<div className="p-4 bg-gray-50 rounded-lg min-h-[60px] text-xl
font-medium text-gray-900 flex items-center justify-center
border-2 border-dashed border-gray-300">
{userWord || (
<span className="text-gray-400">
Clique nas sílabas para formar uma palavra
</span>
)}
</div>
</div>
{/* Palavras Encontradas */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Palavras Encontradas:
</h3>
<div className="flex flex-wrap gap-2">
{completedWords.map((word, index) => (
<span
key={index}
className="px-3 py-1 bg-green-100 text-green-800
rounded-full font-medium"
>
{word}
</span>
))}
</div>
</div>
{/* Botões de Ação */}
<div className="flex gap-4">
<button
onClick={() => setUserWord('')}
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg
hover:bg-gray-300 active:bg-gray-400 transition-colors flex-1
font-medium shadow-sm"
>
Limpar
</button>
<button
onClick={handleVerify}
disabled={!userWord}
className="px-6 py-3 bg-purple-600 text-white rounded-lg
hover:bg-purple-700 active:bg-purple-800 transition-colors flex-1
disabled:bg-gray-300 disabled:cursor-not-allowed
font-medium shadow-sm"
>
Verificar Palavra
</button>
</div>
</div>
);
}

View File

@ -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<ExerciseWord[]>([]);
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: <Puzzle className="w-6 h-6" />,
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: <BookOpen className="w-6 h-6" />,
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: <Mic className="w-6 h-6" />,
words: exerciseWords
.filter(w => w.exercise_type === 'pronunciation')
.map(w => w.word),
}
];
return exercises;
};
return (
<div className="mt-8">
<h3 className="text-xl font-bold text-gray-900 mb-4">
Exercícios Sugeridos
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{generateExercises().map((exercise) => (
<button
key={exercise.type}
onClick={() => handleExerciseSelect(exercise.type)}
className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-500 transition-colors"
>
<div className="flex items-center gap-3 mb-2">
<div className="text-purple-600">
{exercise.icon}
</div>
<h4 className="font-semibold text-gray-900">
{exercise.title}
</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
{exercise.description}
</p>
<div className="text-xs text-gray-500">
{exercise.type === 'word-formation' && (
<div>
Palavras para praticar:
<div className="mt-1 flex flex-wrap gap-1">
{exercise.words.map(word => (
<span key={word} className="bg-purple-100 px-2 py-1 rounded">
{word}
</span>
))}
</div>
</div>
)}
</div>
</button>
))}
</div>
</div>
);
}

View File

@ -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<any>(null);
const [exerciseWords, setExerciseWords] = React.useState<ExerciseWord[]>([]);
const [error, setError] = React.useState<string | null>(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 (
<div className="container mx-auto p-6">
<div className="text-center">
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={() => navigate(-1)}
className="text-purple-600 hover:text-purple-700"
>
Voltar
</button>
</div>
</div>
);
}
if (!exerciseData) {
return (
<div className="container mx-auto p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
<div className="h-64 bg-gray-200 rounded" />
</div>
</div>
);
}
const renderExercise = () => {
if (!exerciseData?.story) {
return <div>Carregando...</div>;
}
switch (type) {
case 'word-formation':
return (
<WordFormation
words={exerciseWords.map(w => w.word)}
storyId={storyId as string}
/>
);
case 'sentence-completion':
return (
<SentenceCompletion
story={exerciseData.story}
/>
);
case 'pronunciation-practice':
return (
<PronunciationPractice
words={exerciseWords.map(w => w.word)}
/>
);
default:
return <div>Exercício não encontrado</div>;
}
};
return (
<div className="container mx-auto p-6">
{/* Cabeçalho */}
<div className="mb-8">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"
>
<ArrowLeft className="w-5 h-5" />
Voltar para história
</button>
</div>
{/* Exercício */}
{renderExercise()}
</div>
);
}

View File

@ -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() {
</Dialog.Portal>
</Dialog.Root>
</div>
{/* Depois do StoryMetrics */}
{recordings.length > 0 && (
<ExerciseSuggestions
storyId={story.id}
storyText={story.content.pages[currentPage].text}
readingMetrics={{
difficultWords: getLatestRecording().improvements
.filter(imp => 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
}}
/>
)}
</div>
);
}

View File

@ -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: <EducationalForParents />,
},
{
path: '/aluno/historias/:id/exercicios/:type',
element: <ExercisePage />
}
]);

View File

@ -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,