From a8c332d4425def5c0c4806cb5acbd9b715a951f2 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 27 Dec 2024 13:24:25 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20adiciona=20processamento=20autom=C3=A1t?= =?UTF-8?q?ico=20de=20=C3=A1udio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implementa Edge Function para processamento de áudio - Adiciona integração com OpenAI Whisper e GPT-4 - Configura Database Trigger para story_recordings - Implementa análise automática de leitura - Atualiza documentação e variáveis de ambiente --- .gitignore | 2 + CHANGELOG.md | 55 +---- Dockerfile.dev | 15 ++ docker-compose.dev.yml | 31 +++ docker-compose.yml | 4 +- supabase/functions/process-audio/analyzer.ts | 169 +++++++++++++++ supabase/functions/process-audio/hooks.ts | 44 ++++ supabase/functions/process-audio/index.ts | 212 +++++++++++-------- supabase/functions/process-audio/whisper.ts | 46 ++++ 9 files changed, 438 insertions(+), 140 deletions(-) create mode 100644 Dockerfile.dev create mode 100644 docker-compose.dev.yml create mode 100644 supabase/functions/process-audio/analyzer.ts create mode 100644 supabase/functions/process-audio/hooks.ts create mode 100644 supabase/functions/process-audio/whisper.ts diff --git a/.gitignore b/.gitignore index 2cadf45..0b13fec 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ dist-ssr .env .env.local .env.*.local +.env* +.env.* # Backup files *copy* diff --git a/CHANGELOG.md b/CHANGELOG.md index ec3f2b9..7af05cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,52 +1,19 @@ # Changelog -Todas as mudanças notáveis neste projeto serão documentadas neste arquivo. - -O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/), -e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/). - -## [0.2.0] - 2024-03-21 +## [1.1.0] - 2024-01-27 ### Adicionado -- Configuração Docker para ambiente de produção -- Pipeline de CI/CD no Gitea Actions -- Integração com Redis para cache -- Healthcheck da aplicação -- Adiciona seções: - - Hero com CTA e social proof - - Problemas e Soluções - - Como a Magia Acontece - - Comparação antes/depois - - Benefícios Mágicos em layout horizontal - - Testimoniais - - Planos e preços - - FAQ - - CTA final - - Footer +- Função de processamento de áudio (process-audio) +- Integração com OpenAI Whisper para transcrição +- Análise de leitura usando GPT-4 +- Database Trigger para processamento automático de gravações ### Técnico -- Dockerfile com multi-stage build para otimização -- Configuração de registry no Gitea -- Cache de histórias com Redis -- Implementação de cliente Redis com: - - Retry strategy otimizada - - Reconexão automática - - Tipagem forte - - Funções utilitárias para cache -- Scripts de deploy e monitoramento +- Implementação de Edge Function no Supabase +- Configuração de ambiente de desenvolvimento com Docker +- Integração com banco de dados para story_recordings +- Sistema de análise de métricas de leitura ### Modificado -- Atualização do next.config.js para suporte standalone -- Adaptação da API para usar Redis cache -- Configuração de redes Docker -- Otimiza UX/UI com: - - Animações suaves - - Gradientes modernos - - Layout responsivo - - Elementos interativos - - Social proof estratégico - -### Segurança -- Implementação de healthchecks -- Configuração de redes isoladas -- Proteção de variáveis de ambiente \ No newline at end of file +- Estrutura de armazenamento de gravações +- Fluxo de processamento de áudio automatizado \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..6a0c40b --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM node:18-alpine + +WORKDIR /app + +# Adicionar dependência do Redis +RUN apk add --no-cache redis + +COPY package*.json ./ +COPY yarn.lock ./ + +RUN yarn install + +COPY . . + +CMD ["yarn", "dev"] \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..458353a --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,31 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile.dev + ports: + - "3000:3000" + volumes: + - .:/app + - /app/node_modules + environment: + - NODE_ENV=development + - NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} + - NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY} + - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - REDIS_URL=redis://redis:6379 + depends_on: + - redis + + redis: + image: redis:alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + +volumes: + redis_data: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4ea0146..189a24c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,5 @@ services: condition: on-failure networks: - traefik-public: + network_public: external: true - redis-network: - external: true \ No newline at end of file diff --git a/supabase/functions/process-audio/analyzer.ts b/supabase/functions/process-audio/analyzer.ts new file mode 100644 index 0000000..a828a26 --- /dev/null +++ b/supabase/functions/process-audio/analyzer.ts @@ -0,0 +1,169 @@ +import { OpenAI } from 'https://deno.land/x/openai@v4.20.1/mod.ts' +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' + +const openai = new OpenAI({ + apiKey: Deno.env.get('OPENAI_API_KEY') +}) + +interface ReadingAnalysis { + fluency_score: number + pronunciation_score: number + accuracy_score: number + comprehension_score: number + words_per_minute: number + pause_count: number + error_count: number + self_corrections: number + strengths: string[] + improvements: string[] + suggestions: string + raw_data: any +} + +export async function analyzeReading( + transcription: string, + storyId: string +): Promise { + try { + console.log('Analisando leitura para story_id:', storyId) + + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ) + + // Busca a história com o texto da página + const { data: storyPages, error: storyError } = await supabase + .from('story_pages') + .select('text') + .eq('story_id', storyId) + .order('page_number', { ascending: true }) + + if (storyError) { + console.error('Erro ao buscar história:', storyError) + throw storyError + } + + if (!storyPages || storyPages.length === 0) { + console.error('Dados da história inválidos:', storyPages) + throw new Error('Texto da história não encontrado') + } + + // Concatena todos os textos das páginas + const originalText = storyPages.map(page => page.text).join(' ') + console.log('Texto original:', originalText) + console.log('Transcrição:', transcription) + + // Análise com GPT-4 + const analysis = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + role: "system", + content: `Você é um especialista em análise de leitura infantil. + Analise a transcrição comparando com o texto original. + Forneça métricas detalhadas e feedback construtivo. + Retorne apenas o JSON solicitado, sem texto adicional.` + }, + { + role: "user", + content: ` + Texto Original: "${originalText}" + Transcrição: "${transcription}" + + Analise e retorne as métricas no formato JSON.` + } + ], + response_format: { type: "json_object" }, + temperature: 0.7, + max_tokens: 1000, + functions: [ + { + name: "analyze_reading", + description: "Analisa a leitura e retorna métricas", + parameters: { + type: "object", + properties: { + fluency_score: { + type: "number", + description: "Pontuação de fluência (0-100)" + }, + pronunciation_score: { + type: "number", + description: "Pontuação de pronúncia (0-100)" + }, + accuracy_score: { + type: "number", + description: "Pontuação de precisão (0-100)" + }, + comprehension_score: { + type: "number", + description: "Pontuação de compreensão (0-100)" + }, + words_per_minute: { + type: "number", + description: "Palavras por minuto" + }, + pause_count: { + type: "number", + description: "Número de pausas" + }, + error_count: { + type: "number", + description: "Número de erros" + }, + self_corrections: { + type: "number", + description: "Número de autocorreções" + }, + strengths: { + type: "array", + items: { type: "string" }, + description: "3-5 pontos fortes" + }, + improvements: { + type: "array", + items: { type: "string" }, + description: "3-5 pontos para melhorar" + }, + suggestions: { + type: "string", + description: "Sugestão personalizada" + } + }, + required: [ + "fluency_score", + "pronunciation_score", + "accuracy_score", + "comprehension_score", + "words_per_minute", + "pause_count", + "error_count", + "self_corrections", + "strengths", + "improvements", + "suggestions" + ] + } + } + ] + }) + + if (!analysis.choices[0]?.message?.function_call?.arguments) { + throw new Error('Análise vazia do GPT') + } + + console.log('Resposta do GPT:', analysis.choices[0].message.function_call.arguments) + + const result = JSON.parse(analysis.choices[0].message.function_call.arguments) + + return { + ...result, + raw_data: analysis + } + + } catch (error) { + console.error('Erro detalhado na análise:', error) + throw error + } +} \ No newline at end of file diff --git a/supabase/functions/process-audio/hooks.ts b/supabase/functions/process-audio/hooks.ts new file mode 100644 index 0000000..615e8cf --- /dev/null +++ b/supabase/functions/process-audio/hooks.ts @@ -0,0 +1,44 @@ +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' +import { processAudio } from './index.ts' + +interface WebhookPayload { + type: 'INSERT' | 'UPDATE' | 'DELETE' + table: string + schema: string + record: { + id: string + story_id: string + student_id: string + audio_url: string + status: string + [key: string]: any + } + old_record: null | Record +} + +serve(async (req) => { + try { + const payload: WebhookPayload = await req.json() + + // Verifica se é uma inserção em story_recordings + if ( + payload.type === 'INSERT' && + payload.table === 'story_recordings' && + payload.schema === 'public' && + payload.record.status === 'pending_analysis' + ) { + await processAudio(payload.record) + } + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' } + }) + + } catch (error) { + console.error('Hook error:', error) + return new Response( + JSON.stringify({ error: error.message }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ) + } +}) \ No newline at end of file diff --git a/supabase/functions/process-audio/index.ts b/supabase/functions/process-audio/index.ts index 7b9b06d..6e679ef 100644 --- a/supabase/functions/process-audio/index.ts +++ b/supabase/functions/process-audio/index.ts @@ -1,127 +1,153 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' -import { Configuration, OpenAIApi } from 'https://esm.sh/openai@3.1.0' +import { processAudioWithWhisper } from './whisper.ts' +import { analyzeReading } from './analyzer.ts' -// Configurar OpenAI -const openaiConfig = new Configuration({ - apiKey: Deno.env.get('OPENAI_API_KEY') -}) -const openai = new OpenAIApi(openaiConfig) +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} -// Configurar Supabase -const supabaseUrl = Deno.env.get('SUPABASE_URL') -const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY') -const supabase = createClient(supabaseUrl, supabaseAnonKey) +interface AudioRecord { + id: string + story_id: string + student_id: string + audio_url: string + status: 'pending_analysis' | 'processing' | 'completed' | 'error' + analysis: any + created_at: string + transcription: string | null + processed_at: string | null + error_message: string | null + fluency_score: number | null + pronunciation_score: number | null + accuracy_score: number | null + comprehension_score: number | null + words_per_minute: number | null + pause_count: number | null + error_count: number | null + self_corrections: number | null + strengths: string[] + improvements: string[] + suggestions: string | null +} serve(async (req) => { - try { - // Extrair dados do webhook - const { record } = await req.json() - const { id, audio_url, story_id } = record + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } - // Atualizar status para processing + let data: any = null + + try { + // Log dos headers para debug + console.log('Headers:', Object.fromEntries(req.headers.entries())) + + // Validação inicial do body + const body = await req.text() + console.log('Raw body:', body) + console.log('Body length:', body.length) + console.log('Body type:', typeof body) + + data = JSON.parse(body) + + // Validação do record + if (!data.record || !data.record.id || !data.record.audio_url) { + return new Response( + JSON.stringify({ + error: 'Payload inválido', + details: 'record, id e audio_url são obrigatórios', + receivedData: data + }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ) + } + + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ) + + const audioRecord = data.record as AudioRecord + + // 1. Atualiza status para processing await supabase .from('story_recordings') .update({ status: 'processing' }) - .eq('id', id) + .eq('id', audioRecord.id) - // Buscar texto original da história - const { data: storyData } = await supabase - .from('stories') - .select('content') - .eq('id', story_id) - .single() + // 2. Processa o áudio com Whisper + const transcription = await processAudioWithWhisper(audioRecord.audio_url) - const originalText = storyData.content.pages[0].text + // 3. Analisa a leitura + const analysis = await analyzeReading(transcription, audioRecord.story_id) - // 1. Transcrever áudio com Whisper - const audioResponse = await fetch(audio_url) - const audioBlob = await audioResponse.blob() - - const transcription = await openai.createTranscription( - audioBlob, - 'whisper-1', - 'pt', - 'verbose_json' - ) - - // 2. Analisar com GPT-4 - const analysis = await openai.createChatCompletion({ - model: "gpt-4", - messages: [ - { - role: "system", - content: `Você é um especialista em análise de leitura infantil. - Analise a transcrição comparando com o texto original. - Forneça uma análise detalhada em formato JSON com métricas de 0-100.` - }, - { - role: "user", - content: ` - Texto Original: "${originalText}" - Transcrição: "${transcription.data.text}" - - Analise e retorne um JSON com: - { - "metrics": { - "fluency": number, - "pronunciation": number, - "accuracy": number, - "comprehension": number - }, - "feedback": { - "strengths": string[], - "improvements": string[], - "suggestions": string - }, - "details": { - "wordsPerMinute": number, - "pauseCount": number, - "errorCount": number, - "selfCorrections": number - } - }` - } - ] - }) - - const analysisResult = JSON.parse(analysis.data.choices[0].message.content) - - // 3. Atualizar registro com resultados - await supabase + // 4. Atualiza o registro com os resultados + const { error: updateError } = await supabase .from('story_recordings') .update({ - transcription: transcription.data.text, - metrics: analysisResult.metrics, - feedback: analysisResult.feedback, - details: analysisResult.details, - status: 'analyzed', - processed_at: new Date().toISOString() + status: 'completed', + transcription, + processed_at: new Date().toISOString(), + fluency_score: analysis.fluency_score, + pronunciation_score: analysis.pronunciation_score, + accuracy_score: analysis.accuracy_score, + comprehension_score: analysis.comprehension_score, + words_per_minute: analysis.words_per_minute, + pause_count: analysis.pause_count, + error_count: analysis.error_count, + self_corrections: analysis.self_corrections, + strengths: analysis.strengths, + improvements: analysis.improvements, + suggestions: analysis.suggestions, + analysis: analysis.raw_data }) - .eq('id', id) + .eq('id', audioRecord.id) + + if (updateError) throw updateError return new Response( - JSON.stringify({ success: true }), - { headers: { 'Content-Type': 'application/json' } } + JSON.stringify({ + message: 'Áudio processado com sucesso', + data: analysis + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } ) } catch (error) { - console.error('Erro:', error) + console.error('Erro ao processar áudio:', error) + + // Só tenta atualizar o registro se tiver o ID + if (data?.record?.id) { + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ) - // Atualizar registro com erro - if (error.record?.id) { await supabase .from('story_recordings') .update({ status: 'error', error_message: error.message }) - .eq('id', error.record.id) + .eq('id', data.record.id) } return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers: { 'Content-Type': 'application/json' } } + JSON.stringify({ + error: 'Falha ao processar áudio', + details: error.message + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + } ) } }) \ No newline at end of file diff --git a/supabase/functions/process-audio/whisper.ts b/supabase/functions/process-audio/whisper.ts new file mode 100644 index 0000000..9200c97 --- /dev/null +++ b/supabase/functions/process-audio/whisper.ts @@ -0,0 +1,46 @@ +import { OpenAI } from 'https://deno.land/x/openai@v4.20.1/mod.ts' + +const openai = new OpenAI({ + apiKey: Deno.env.get('OPENAI_API_KEY') +}) + +export async function processAudioWithWhisper(audioUrl: string): Promise { + try { + // Download do áudio + const audioResponse = await fetch(audioUrl) + if (!audioResponse.ok) { + throw new Error('Falha ao baixar áudio') + } + const audioBlob = await audioResponse.blob() + + // Debug do áudio + console.log('Audio blob:', { + size: audioBlob.size, + type: audioBlob.type + }) + + // Converte Blob para File + const audioFile = new File([audioBlob], 'audio.mp3', { + type: audioBlob.type || 'audio/mpeg' + }) + + // Transcrição com Whisper + const transcription = await openai.audio.transcriptions.create({ + file: audioFile, + model: 'whisper-1', + language: 'pt' + // removido response_format pois estava causando erro + }) + + console.log('Transcription response:', transcription) // Debug + + if (!transcription || typeof transcription === 'undefined') { + throw new Error('Resposta da transcrição vazia') + } + + return transcription.text || '' + } catch (error) { + console.error('Erro detalhado na transcrição:', error) + throw error + } +} \ No newline at end of file