Adicionado fluxo de revisão de transcrição de voz antes da análise de IA

This commit is contained in:
2026-05-27 23:59:45 +00:00
parent 3cd5a4479b
commit d405441dda
3 changed files with 168 additions and 56 deletions
+113 -26
View File
@@ -1993,6 +1993,14 @@ const initApp = () => {
const btnDiscardReport = document.getElementById('btnDiscardReport');
const btnSaveReport = document.getElementById('btnSaveReport');
// Novos elementos de transcrição
const recordStateTranscribed = document.getElementById('recordStateTranscribed');
const transcriptionTextarea = document.getElementById('transcriptionTextarea');
const btnDiscardTranscription = document.getElementById('btnDiscardTranscription');
const btnAnalyzeTranscription = document.getElementById('btnAnalyzeTranscription');
const recordProcessingText = document.getElementById('recordProcessingText');
const recordProcessingSubtext = document.getElementById('recordProcessingSubtext');
let mediaRecorder = null;
let audioChunks = [];
let recordingStartTime = null;
@@ -2002,6 +2010,40 @@ const initApp = () => {
let generatedTurma = 'não informada';
let generatedTags = [];
// Fechar modal - colocada no topo para evitar problemas de inicialização
function closeRecordModal() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch (e) {}
if (mediaRecorder.stream) {
mediaRecorder.stream.getTracks().forEach(t => t.stop());
}
}
clearInterval(timerInterval);
audioChunks = [];
generatedReport = null;
recordModal.style.display = 'none';
}
// Função para controlar a exibição dos estados
function showRecordState(state) {
recordStateRecording.style.display = 'none';
recordStateProcessing.style.display = 'none';
if (recordStateTranscribed) recordStateTranscribed.style.display = 'none';
recordStatePreview.style.display = 'none';
if (state === 'recording') {
recordStateRecording.style.display = 'flex';
} else if (state === 'processing') {
recordStateProcessing.style.display = 'flex';
} else if (state === 'transcribed') {
if (recordStateTranscribed) recordStateTranscribed.style.display = 'flex';
} else if (state === 'preview') {
recordStatePreview.style.display = 'flex';
}
}
// Abrir modal e iniciar gravação
if (fabRecord) {
fabRecord.addEventListener('click', async () => {
@@ -2009,9 +2051,7 @@ const initApp = () => {
generatedReport = null;
// Mostra estado inicial (gravando)
recordStateRecording.style.display = 'flex';
recordStateProcessing.style.display = 'none';
recordStatePreview.style.display = 'none';
showRecordState('recording');
recordModal.style.display = 'flex';
// Inicia timer
@@ -2056,20 +2096,25 @@ const initApp = () => {
const mins = Math.floor(elapsed / 60).toString().padStart(2, '0');
const secs = (elapsed % 60).toString().padStart(2, '0');
recordTimer.textContent = `${mins}:${secs}`;
};
}
// Parar gravação e processar
// Parar gravação e transcrever
if (btnStopRecord) {
btnStopRecord.addEventListener('click', async () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch (e) {}
if (mediaRecorder.stream) {
mediaRecorder.stream.getTracks().forEach(t => t.stop());
}
}
clearInterval(timerInterval);
// Mostra processando
recordStateRecording.style.display = 'none';
recordStateProcessing.style.display = 'flex';
// Mostra processando com texto de transcrição
if (recordProcessingText) recordProcessingText.textContent = 'Transcrevendo áudio...';
if (recordProcessingSubtext) recordProcessingSubtext.textContent = 'Aguarde a transcrição por Whisper';
showRecordState('processing');
// Envia áudio para servidor
const blob = new Blob(audioChunks, { type: 'audio/webm' });
@@ -2081,6 +2126,54 @@ const initApp = () => {
method: 'POST',
body: formData
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || 'Erro na transcrição');
}
const data = await res.json();
// Exibe o texto transcrito na área de texto para edição
if (transcriptionTextarea) {
transcriptionTextarea.value = data.transcribedText || '';
}
showRecordState('transcribed');
} catch (err) {
console.error('Erro ao processar observação:', err);
alert('Erro ao transcrever áudio: ' + err.message);
closeRecordModal();
}
});
}
// Ação de analisar transcrição
if (btnAnalyzeTranscription) {
btnAnalyzeTranscription.addEventListener('click', async () => {
const text = transcriptionTextarea ? transcriptionTextarea.value.trim() : '';
if (!text || text.length < 5) {
alert('Por favor, digite ou revise a transcrição (mínimo de 5 caracteres).');
return;
}
// Mostra processando com texto de análise
if (recordProcessingText) recordProcessingText.textContent = 'Analisando observação...';
if (recordProcessingSubtext) recordProcessingSubtext.textContent = 'Gerando relatório pedagógico estruturado';
showRecordState('processing');
try {
const res = await fetch('/api/observacao/analisar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transcribedText: text })
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || 'Erro na análise');
}
const data = await res.json();
generatedReport = data.relatorio || data.report;
generatedCriancas = data.criancas;
@@ -2088,17 +2181,23 @@ const initApp = () => {
generatedTags = data.tags || [];
showReportPreview(data);
} catch (err) {
console.error('Erro ao processar observação:', err);
alert('Erro ao processar sua observação. Tente novamente.');
closeRecordModal();
console.error('Erro ao analisar observação:', err);
alert('Erro ao analisar observação: ' + err.message);
showRecordState('transcribed');
}
});
}
// Ação de descartar transcrição
if (btnDiscardTranscription) {
btnDiscardTranscription.addEventListener('click', () => {
closeRecordModal();
});
}
// Mostrar prévia do relatório
function showReportPreview(data) {
recordStateProcessing.style.display = 'none';
recordStatePreview.style.display = 'flex';
showRecordState('preview');
const now = new Date();
previewDateTime.textContent = now.toLocaleString('pt-BR', {
@@ -2123,7 +2222,7 @@ const initApp = () => {
${marked.parse(generatedReport || 'Relatório não disponível.')}
`;
reportContent.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b));
};
}
// Descartar relatório
if (btnDiscardReport) {
@@ -2173,18 +2272,6 @@ const initApp = () => {
btnExitRecord.addEventListener('click', closeRecordModal);
}
// Fechar modal
function closeRecordModal() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
mediaRecorder.stream.getTracks().forEach(t => t.stop());
}
clearInterval(timerInterval);
audioChunks = [];
generatedReport = null;
recordModal.style.display = 'none';
};
// Fechar clicando fora
if (recordModal) {
recordModal.addEventListener('click', (e) => {
+20 -2
View File
@@ -296,8 +296,26 @@
<!-- Estado: processando -->
<div id="recordStateProcessing" class="record-state" style="display: none;">
<div class="record-spinner"></div>
<p>Analisando sua observação...</p>
<small style="color: var(--text-muted);">Transcrevendo e gerando relatório</small>
<p id="recordProcessingText">Analisando sua observação...</p>
<small id="recordProcessingSubtext" style="color: var(--text-muted);">Transcrevendo e gerando relatório</small>
</div>
<!-- Estado: Transcrição Concluída (Revisão) -->
<div id="recordStateTranscribed" class="record-state" style="display: none; width: 100%;">
<h4 style="margin-bottom: 8px; color: var(--text-main); display: flex; align-items: center; gap: 8px; width: 100%; justify-content: flex-start;">
<span>✍️ Revisar Transcrição</span>
</h4>
<p class="record-hint" style="margin-bottom: 12px; text-align: left; width: 100%;">Você pode ajustar o texto abaixo antes da análise da IA:</p>
<textarea id="transcriptionTextarea" class="transcription-textarea" style="width: 100%; min-height: 120px; padding: 12px; border-radius: 8px; border: 1px solid var(--border-light); background-color: var(--bg-card); color: var(--text-main); font-family: inherit; font-size: 14px; line-height: 1.5; resize: vertical; margin-bottom: 16px;" placeholder="Digite ou ajuste o que foi falado..."></textarea>
<div class="preview-actions" style="display: flex; gap: 12px; width: 100%; justify-content: flex-end;">
<button id="btnDiscardTranscription" class="btn-discard" style="flex: 1; max-width: 150px;">Descartar</button>
<button id="btnAnalyzeTranscription" class="btn-save-report" style="flex: 1; max-width: 200px; display: flex; align-items: center; justify-content: center; gap: 8px;">
<span>Analisar com IA</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="width: 16px; height: 16px;">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</div>
</div>
<!-- Estado: prévia -->
+33 -26
View File
@@ -342,7 +342,7 @@ async function initDatabase() {
}
}
// Rota: POST /api/observacao/transcrever — recebe áudio, transcreve via Whisper, gera relatório
// Rota: POST /api/observacao/transcrever — recebe áudio e transcreve via Whisper
app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum áudio recebido' });
@@ -355,18 +355,15 @@ app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'
else if (req.file.mimetype.includes('wav')) ext = 'wav';
else if (req.file.mimetype.includes('mpeg')) ext = 'mp3';
}
const tempPath = `/tmp/obs_${Date.now()}.${ext}`;
try {
// Salva arquivo temporário a partir do buffer
fs.writeFileSync(tempPath, req.file.buffer);
let transcribedText = '';
const fileBlob = new Blob([req.file.buffer], { type: req.file.mimetype || 'audio/webm' });
if (process.env.OPENAI_API_KEY) {
console.log('[Smart Transcription] Usando OpenAI Whisper...');
const whisperFormData = new FormData();
whisperFormData.append('file', fs.createReadStream(tempPath));
whisperFormData.append('file', fileBlob, `audio.${ext}`);
whisperFormData.append('model', 'whisper-1');
whisperFormData.append('language', 'pt');
whisperFormData.append('response_format', 'text');
@@ -386,9 +383,9 @@ app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'
}
transcribedText = await whisperRes.text();
} else if (process.env.GROQ_API_KEY) {
console.log('[Smart Transcription] Usando Groq Whisper como fallback...');
console.log('[Smart Transcription] Usando Groq Whisper...');
const whisperFormData = new FormData();
whisperFormData.append('file', fs.createReadStream(tempPath));
whisperFormData.append('file', fileBlob, `audio.${ext}`);
whisperFormData.append('model', 'whisper-large-v3');
whisperFormData.append('language', 'pt');
whisperFormData.append('response_format', 'text');
@@ -411,34 +408,49 @@ app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'
throw new Error('Nenhuma chave de API configurada para transcrição de áudio (OPENAI_API_KEY ou GROQ_API_KEY).');
}
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.' });
if (!transcribedText || transcribedText.trim().length < 2) {
return res.status(400).json({ error: 'O áudio não contém fala identificável.' });
}
// Gera relatório estruturado via GPT
// Envia transcrição + pedido de extrair: crianças, turma, tags, relatório
const reportPrompt = `Você é a Kemily, assistente pedagógica da professora Camila Martella Gasparini Reifonas.
res.json({ transcribedText });
Ela acabou de gravar uma observação em voz sobre seus alunos da educação infantil. Sua tarefa é ANALISAR a transcrição e retornar UM OBJETO JSON (sem markdown, sem código, apenas o JSON puro) com:
} catch (err) {
console.error('Erro transcrever:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: POST /api/observacao/analisar — recebe texto transcrito e gera relatório pedagógico
app.post('/api/observacao/analisar', requireAuth, async (req, res) => {
const { transcribedText } = req.body;
if (!transcribedText || transcribedText.trim().length < 5) {
return res.status(400).json({ error: 'Texto muito curto ou inexistente para análise.' });
}
try {
const agentConfig = readAgentConfig();
const agentName = agentConfig.agentName || "Kemily";
const reportPrompt = `Você é a ${agentName}, assistente pedagógica da professora Camila Martella Gasparini Reifonas.
Ela acabou de registrar uma observação sobre seus alunos da educação infantil. Sua tarefa é ANALISAR o texto e retornar UM OBJETO JSON (sem markdown, sem código, apenas o JSON puro) com:
{
"criancas": ["lista de nomes de crianças mencionadas ou 'null' se não identificar nenhuma"],
"turma": "turma mencionada ou 'não informada'",
"tags": ["tag1", "tag2"] (social, cognitivo, motor, linguagem, emocional,轮的哪个),
"tags": ["tag1", "tag2"] (social, cognitivo, motor, linguagem, emocional, colaborativo, autónomo),
"relatorio": "...markdown estruturado do relatório pedagógico..."
}
REGRAS:
- Identifique crianças pelo nome próprio mencionado na fala
- Identifique crianças pelo nome próprio mencionado na fala/texto
- Se não conseguir identificar nomes, use "criancas": null
- Tags: escolha entre social, cognitivo, motor, linguagem, emocional, colaborativo, autónomo
- Relatório em português, linguagem formal-pedagógica, seções claras em **markdown**
- Foque em evidências observáveis
- Máximo 120 palavras no relatório
TRANSCRIÇÃO:
OBSERVAÇÃO REGISTRADA:
"""
${transcribedText}
"""
@@ -583,19 +595,14 @@ Retorne APENAS o JSON válido:`;
throw new Error('Nenhum JSON encontrado na resposta da IA');
}
} catch (parseErr) {
// Se não conseguir parsear JSON, retorna o texto como relatório
console.warn('Não foi possível parsear JSON da resposta da IA:', parseErr.message);
parsedData.relatorio = rawContent;
}
res.json({
transcribedText,
...parsedData
});
res.json(parsedData);
} catch (err) {
console.error('Erro transcrever/salvar:', err);
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
console.error('Erro ao analisar observação:', err);
res.status(500).json({ error: err.message });
}
});