feat: sistema de observação por voz - fase 1
- FAB flutuante para gravar observação - Modal com timer + waveform animado - Botão sair para desistir - MediaRecorder API (webm/opus) - Transcrição via OpenAI Whisper - Relatório estruturado via GPT - Salva em .md com frontmatter (data/hora/timestamp) - Índice JSON para consulta posterior - Rotas: transcrever, salvar, listar observações - multer para upload de áudio
This commit is contained in:
@@ -2,11 +2,18 @@ require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || 'puglindo26';
|
||||
|
||||
// Configuração do multer para uploads de áudio
|
||||
const multerUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 25 * 1024 * 1024 } // 25MB max
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
app.use(cookieParser(process.env.SESSION_SECRET || 'camila-secret'));
|
||||
@@ -180,6 +187,179 @@ app.get('/api/temperature-presets', requireAuth, (req, res) => {
|
||||
res.json(TEMPERATURE_PRESETS);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SISTEMA DE OBSERVAÇÃO POR VOZ
|
||||
// ============================================================
|
||||
const OBSERVACOES_DIR = path.join(__dirname, 'observacoes');
|
||||
const OBS_INDEX_FILE = path.join(OBSERVACOES_DIR, 'index.json');
|
||||
|
||||
// Garante que diretório de observações existe
|
||||
if (!fs.existsSync(OBSERVACOES_DIR)) {
|
||||
fs.mkdirSync(OBSERVACOES_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Lê índice de observações
|
||||
function readObsIndex() {
|
||||
if (!fs.existsSync(OBS_INDEX_FILE)) return { observations: [] };
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(OBS_INDEX_FILE, 'utf8'));
|
||||
} catch { return { observations: [] }; }
|
||||
}
|
||||
|
||||
// Salva índice de observações
|
||||
function writeObsIndex(idx) {
|
||||
fs.writeFileSync(OBS_INDEX_FILE, JSON.stringify(idx, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
// Rota: POST /api/observacao/transcrever — recebe áudio, transcreve via Whisper, gera relatório
|
||||
app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Nenhum áudio recebido' });
|
||||
}
|
||||
|
||||
const tempPath = `/tmp/obs_${Date.now()}.webm`;
|
||||
|
||||
try {
|
||||
// Salva arquivo temporário a partir do buffer
|
||||
fs.writeFileSync(tempPath, req.file.buffer);
|
||||
|
||||
// Transcreve via OpenAI Whisper
|
||||
const whisperFormData = new FormData();
|
||||
whisperFormData.append('file', fs.createReadStream(tempPath));
|
||||
whisperFormData.append('model', 'whisper-1');
|
||||
whisperFormData.append('language', 'pt');
|
||||
whisperFormData.append('response_format', 'text');
|
||||
|
||||
const whisperRes = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
|
||||
},
|
||||
body: whisperFormData
|
||||
});
|
||||
|
||||
if (!whisperRes.ok) {
|
||||
const errText = await whisperRes.text();
|
||||
console.error('Whisper error:', errText);
|
||||
throw new Error('Erro na transcrição');
|
||||
}
|
||||
|
||||
const transcribedText = await whisperRes.text();
|
||||
fs.unlinkSync(tempPath); // Limpa tmp
|
||||
|
||||
if (!transcribedText || transcribedText.trim().length < 5) {
|
||||
return res.json({ report: '⚠️ O áudio estava muito curto ou não pôde ser transcrito. Tente gravar por mais alguns segundos.' });
|
||||
}
|
||||
|
||||
// Gera relatório estruturado via GPT
|
||||
const reportPrompt = `Você é a Kemily, assistente pedagógica da professora Camila Martella Gasparini Reifonas.
|
||||
|
||||
Ela acabou de gravar uma observação em voz sobre seus alunos da educação infantil. Sua tarefa é analisar a transcrição e gerar um RELATÓRIO ESTRUTURADO de observação pedagógica.
|
||||
|
||||
REGRAS DO RELATÓRIO:
|
||||
- Use linguagem formal-pedagógica com calor humano
|
||||
- Estruture em seções claras com_MARKDOWN
|
||||
- Identifique crianças mencionadas, comportamentos, interações, conquistas, dificuldades
|
||||
- Inclua sugestões de seguimento para o planejamento
|
||||
- Foque em evidências observáveis, não interpretções subjetivas
|
||||
- Seja objetiva mas detalhista
|
||||
|
||||
TRANSCRIÇÃO DA OBSERVAÇÃO:
|
||||
"""
|
||||
${transcribedText}
|
||||
"""
|
||||
|
||||
Gerando o relatório...`;
|
||||
|
||||
const reportRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano',
|
||||
messages: [{ role: 'user', content: reportPrompt }],
|
||||
temperature: 0.5,
|
||||
max_tokens: 1500
|
||||
})
|
||||
});
|
||||
|
||||
if (!reportRes.ok) {
|
||||
throw new Error('Erro ao gerar relatório');
|
||||
}
|
||||
|
||||
const reportData = await reportRes.json();
|
||||
const reportText = reportData.choices?.[0]?.message?.content || 'Relatório não pôde ser gerado.';
|
||||
|
||||
res.json({
|
||||
transcribedText,
|
||||
report: reportText
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro transcrever/salvar:', err);
|
||||
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Rota: POST /api/observacao/salvar — salva relatório em .md
|
||||
app.post('/api/observacao/salvar', requireAuth, (req, res) => {
|
||||
const { report } = req.body;
|
||||
if (!report) return res.status(400).json({ error: 'Relatório vazio' });
|
||||
|
||||
const now = new Date();
|
||||
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
const dateStr = now.toISOString().split('T')[0];
|
||||
const timeStr = now.toTimeString().slice(0, 5).replace(':', 'h') + 'min';
|
||||
const timestamp = now.toISOString();
|
||||
|
||||
// Monta markdown com frontmatter
|
||||
const mdContent = `---
|
||||
data: "${dateStr}"
|
||||
hora: "${timeStr}"
|
||||
timestamp: "${timestamp}"
|
||||
---
|
||||
|
||||
${report}
|
||||
`;
|
||||
|
||||
const filename = `${dateStr}_${timeStr}.md`;
|
||||
const monthDir = path.join(OBSERVACOES_DIR, yearMonth);
|
||||
if (!fs.existsSync(monthDir)) fs.mkdirSync(monthDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(monthDir, filename);
|
||||
fs.writeFileSync(filePath, mdContent, 'utf8');
|
||||
|
||||
// Atualiza índice
|
||||
const idx = readObsIndex();
|
||||
idx.observations.unshift({
|
||||
id: Date.now(),
|
||||
filename,
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
timestamp,
|
||||
preview: report.slice(0, 120) + '...'
|
||||
});
|
||||
writeObsIndex(idx);
|
||||
|
||||
res.json({ success: true, filename, path: filePath });
|
||||
});
|
||||
|
||||
// Rota: GET /api/observacoes — lista todas as observações (índice)
|
||||
app.get('/api/observacoes', requireAuth, (req, res) => {
|
||||
const idx = readObsIndex();
|
||||
res.json(idx);
|
||||
});
|
||||
|
||||
// Rota: GET /api/observacoes/:yearMonth/:filename — busca relatório específico
|
||||
app.get('/api/observacoes/:yearMonth/:filename', requireAuth, (req, res) => {
|
||||
const filePath = path.join(OBSERVACOES_DIR, req.params.yearMonth, req.params.filename);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Não encontrado' });
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
// Rota POST para upload de imagem em base64
|
||||
app.post('/api/upload-avatar', requireAuth, (req, res) => {
|
||||
const { avatarType, base64Data } = req.body;
|
||||
|
||||
Reference in New Issue
Block a user