mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-16 13:27:52 +00:00
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:
parent
4d09386d96
commit
a8c332d442
2
.gitignore
vendored
2
.gitignore
vendored
@ -26,6 +26,8 @@ dist-ssr
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env*
|
||||
.env.*
|
||||
|
||||
# Backup files
|
||||
*copy*
|
||||
|
||||
55
CHANGELOG.md
55
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
|
||||
- Estrutura de armazenamento de gravações
|
||||
- Fluxo de processamento de áudio automatizado
|
||||
15
Dockerfile.dev
Normal file
15
Dockerfile.dev
Normal 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
31
docker-compose.dev.yml
Normal 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:
|
||||
@ -27,7 +27,5 @@ services:
|
||||
condition: on-failure
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
network_public:
|
||||
external: true
|
||||
redis-network:
|
||||
external: true
|
||||
169
supabase/functions/process-audio/analyzer.ts
Normal file
169
supabase/functions/process-audio/analyzer.ts
Normal 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
|
||||
}
|
||||
}
|
||||
44
supabase/functions/process-audio/hooks.ts
Normal file
44
supabase/functions/process-audio/hooks.ts
Normal 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' } }
|
||||
)
|
||||
}
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
46
supabase/functions/process-audio/whisper.ts
Normal file
46
supabase/functions/process-audio/whisper.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user