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
+115 -28
View File
@@ -1993,6 +1993,14 @@ const initApp = () => {
const btnDiscardReport = document.getElementById('btnDiscardReport'); const btnDiscardReport = document.getElementById('btnDiscardReport');
const btnSaveReport = document.getElementById('btnSaveReport'); 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 mediaRecorder = null;
let audioChunks = []; let audioChunks = [];
let recordingStartTime = null; let recordingStartTime = null;
@@ -2002,6 +2010,40 @@ const initApp = () => {
let generatedTurma = 'não informada'; let generatedTurma = 'não informada';
let generatedTags = []; 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 // Abrir modal e iniciar gravação
if (fabRecord) { if (fabRecord) {
fabRecord.addEventListener('click', async () => { fabRecord.addEventListener('click', async () => {
@@ -2009,9 +2051,7 @@ const initApp = () => {
generatedReport = null; generatedReport = null;
// Mostra estado inicial (gravando) // Mostra estado inicial (gravando)
recordStateRecording.style.display = 'flex'; showRecordState('recording');
recordStateProcessing.style.display = 'none';
recordStatePreview.style.display = 'none';
recordModal.style.display = 'flex'; recordModal.style.display = 'flex';
// Inicia timer // Inicia timer
@@ -2056,20 +2096,25 @@ const initApp = () => {
const mins = Math.floor(elapsed / 60).toString().padStart(2, '0'); const mins = Math.floor(elapsed / 60).toString().padStart(2, '0');
const secs = (elapsed % 60).toString().padStart(2, '0'); const secs = (elapsed % 60).toString().padStart(2, '0');
recordTimer.textContent = `${mins}:${secs}`; recordTimer.textContent = `${mins}:${secs}`;
}; }
// Parar gravação e processar // Parar gravação e transcrever
if (btnStopRecord) { if (btnStopRecord) {
btnStopRecord.addEventListener('click', async () => { btnStopRecord.addEventListener('click', async () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') { if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop(); try {
mediaRecorder.stream.getTracks().forEach(t => t.stop()); mediaRecorder.stop();
} catch (e) {}
if (mediaRecorder.stream) {
mediaRecorder.stream.getTracks().forEach(t => t.stop());
}
} }
clearInterval(timerInterval); clearInterval(timerInterval);
// Mostra processando // Mostra processando com texto de transcrição
recordStateRecording.style.display = 'none'; if (recordProcessingText) recordProcessingText.textContent = 'Transcrevendo áudio...';
recordStateProcessing.style.display = 'flex'; if (recordProcessingSubtext) recordProcessingSubtext.textContent = 'Aguarde a transcrição por Whisper';
showRecordState('processing');
// Envia áudio para servidor // Envia áudio para servidor
const blob = new Blob(audioChunks, { type: 'audio/webm' }); const blob = new Blob(audioChunks, { type: 'audio/webm' });
@@ -2081,6 +2126,54 @@ const initApp = () => {
method: 'POST', method: 'POST',
body: formData 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(); const data = await res.json();
generatedReport = data.relatorio || data.report; generatedReport = data.relatorio || data.report;
generatedCriancas = data.criancas; generatedCriancas = data.criancas;
@@ -2088,17 +2181,23 @@ const initApp = () => {
generatedTags = data.tags || []; generatedTags = data.tags || [];
showReportPreview(data); showReportPreview(data);
} catch (err) { } catch (err) {
console.error('Erro ao processar observação:', err); console.error('Erro ao analisar observação:', err);
alert('Erro ao processar sua observação. Tente novamente.'); alert('Erro ao analisar observação: ' + err.message);
closeRecordModal(); showRecordState('transcribed');
} }
}); });
} }
// Ação de descartar transcrição
if (btnDiscardTranscription) {
btnDiscardTranscription.addEventListener('click', () => {
closeRecordModal();
});
}
// Mostrar prévia do relatório // Mostrar prévia do relatório
function showReportPreview(data) { function showReportPreview(data) {
recordStateProcessing.style.display = 'none'; showRecordState('preview');
recordStatePreview.style.display = 'flex';
const now = new Date(); const now = new Date();
previewDateTime.textContent = now.toLocaleString('pt-BR', { previewDateTime.textContent = now.toLocaleString('pt-BR', {
@@ -2123,7 +2222,7 @@ const initApp = () => {
${marked.parse(generatedReport || 'Relatório não disponível.')} ${marked.parse(generatedReport || 'Relatório não disponível.')}
`; `;
reportContent.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b)); reportContent.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b));
}; }
// Descartar relatório // Descartar relatório
if (btnDiscardReport) { if (btnDiscardReport) {
@@ -2173,18 +2272,6 @@ const initApp = () => {
btnExitRecord.addEventListener('click', closeRecordModal); 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 // Fechar clicando fora
if (recordModal) { if (recordModal) {
recordModal.addEventListener('click', (e) => { recordModal.addEventListener('click', (e) => {
+20 -2
View File
@@ -296,8 +296,26 @@
<!-- Estado: processando --> <!-- Estado: processando -->
<div id="recordStateProcessing" class="record-state" style="display: none;"> <div id="recordStateProcessing" class="record-state" style="display: none;">
<div class="record-spinner"></div> <div class="record-spinner"></div>
<p>Analisando sua observação...</p> <p id="recordProcessingText">Analisando sua observação...</p>
<small style="color: var(--text-muted);">Transcrevendo e gerando relatório</small> <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> </div>
<!-- Estado: prévia --> <!-- 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) => { app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'), async (req, res) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'Nenhum áudio recebido' }); 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('wav')) ext = 'wav';
else if (req.file.mimetype.includes('mpeg')) ext = 'mp3'; else if (req.file.mimetype.includes('mpeg')) ext = 'mp3';
} }
const tempPath = `/tmp/obs_${Date.now()}.${ext}`;
try { try {
// Salva arquivo temporário a partir do buffer
fs.writeFileSync(tempPath, req.file.buffer);
let transcribedText = ''; let transcribedText = '';
const fileBlob = new Blob([req.file.buffer], { type: req.file.mimetype || 'audio/webm' });
if (process.env.OPENAI_API_KEY) { if (process.env.OPENAI_API_KEY) {
console.log('[Smart Transcription] Usando OpenAI Whisper...'); console.log('[Smart Transcription] Usando OpenAI Whisper...');
const whisperFormData = new FormData(); const whisperFormData = new FormData();
whisperFormData.append('file', fs.createReadStream(tempPath)); whisperFormData.append('file', fileBlob, `audio.${ext}`);
whisperFormData.append('model', 'whisper-1'); whisperFormData.append('model', 'whisper-1');
whisperFormData.append('language', 'pt'); whisperFormData.append('language', 'pt');
whisperFormData.append('response_format', 'text'); whisperFormData.append('response_format', 'text');
@@ -386,9 +383,9 @@ app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'
} }
transcribedText = await whisperRes.text(); transcribedText = await whisperRes.text();
} else if (process.env.GROQ_API_KEY) { } 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(); const whisperFormData = new FormData();
whisperFormData.append('file', fs.createReadStream(tempPath)); whisperFormData.append('file', fileBlob, `audio.${ext}`);
whisperFormData.append('model', 'whisper-large-v3'); whisperFormData.append('model', 'whisper-large-v3');
whisperFormData.append('language', 'pt'); whisperFormData.append('language', 'pt');
whisperFormData.append('response_format', 'text'); 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).'); 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 < 2) {
return res.status(400).json({ error: 'O áudio não contém fala identificável.' });
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 res.json({ transcribedText });
// 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.
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"], "criancas": ["lista de nomes de crianças mencionadas ou 'null' se não identificar nenhuma"],
"turma": "turma mencionada ou 'não informada'", "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..." "relatorio": "...markdown estruturado do relatório pedagógico..."
} }
REGRAS: 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 - Se não conseguir identificar nomes, use "criancas": null
- Tags: escolha entre social, cognitivo, motor, linguagem, emocional, colaborativo, autónomo - 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** - Relatório em português, linguagem formal-pedagógica, seções claras em **markdown**
- Foque em evidências observáveis - Foque em evidências observáveis
- Máximo 120 palavras no relatório - Máximo 120 palavras no relatório
TRANSCRIÇÃO: OBSERVAÇÃO REGISTRADA:
""" """
${transcribedText} ${transcribedText}
""" """
@@ -583,19 +595,14 @@ Retorne APENAS o JSON válido:`;
throw new Error('Nenhum JSON encontrado na resposta da IA'); throw new Error('Nenhum JSON encontrado na resposta da IA');
} }
} catch (parseErr) { } 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); console.warn('Não foi possível parsear JSON da resposta da IA:', parseErr.message);
parsedData.relatorio = rawContent; parsedData.relatorio = rawContent;
} }
res.json({ res.json(parsedData);
transcribedText,
...parsedData
});
} catch (err) { } catch (err) {
console.error('Erro transcrever/salvar:', err); console.error('Erro ao analisar observação:', err);
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });