feat: adiciona processamento automático de áudio

- 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
This commit is contained in:
Lucas Santana 2024-12-27 13:24:25 -03:00
parent 4d09386d96
commit a8c332d442
9 changed files with 438 additions and 140 deletions

2
.gitignore vendored
View File

@ -26,6 +26,8 @@ dist-ssr
.env
.env.local
.env.*.local
.env*
.env.*
# Backup files
*copy*

View File

@ -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
- Estrutura de armazenamento de gravações
- Fluxo de processamento de áudio automatizado

15
Dockerfile.dev Normal file
View File

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

31
docker-compose.dev.yml Normal file
View File

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

View File

@ -27,7 +27,5 @@ services:
condition: on-failure
networks:
traefik-public:
network_public:
external: true
redis-network:
external: true

View File

@ -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<ReadingAnalysis> {
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
}
}

View File

@ -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<string, any>
}
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' } }
)
}
})

View File

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

View File

@ -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<string> {
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
}
}