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:
2026-05-27 15:46:03 +00:00
parent d309b03b55
commit ea8e33c891
72 changed files with 8531 additions and 4 deletions
+180
View File
@@ -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;