feat: fase 2 - detecção de crianças, tags, filtros e histórico

- GPT retorna JSON estruturado: criancas, turma, tags, relatorio
- Frontmatter agora inclui criancas[], turma, tags[] no .md
- Índice JSON estruturado com metadados completos
- Prévia do relatório mostra crianças detectadas + tags + turma
- Modal 'Minhas Observações' na sidebar
- Filtros por mês, tag e busca por criança
- Lista de observações com preview e chips
- Modal de detalhe com copiar texto
- CSS para btn-observacoes, obs-modal, obs-items, tags/chips
This commit is contained in:
2026-05-27 15:57:04 +00:00
parent ea8e33c891
commit 0b35055e23
4 changed files with 434 additions and 25 deletions
+174 -3
View File
@@ -1990,6 +1990,9 @@ const initApp = () => {
let recordingStartTime = null; let recordingStartTime = null;
let timerInterval = null; let timerInterval = null;
let generatedReport = null; let generatedReport = null;
let generatedCriancas = null;
let generatedTurma = 'não informada';
let generatedTags = [];
// Abrir modal e iniciar gravação // Abrir modal e iniciar gravação
if (fabRecord) { if (fabRecord) {
@@ -2059,7 +2062,10 @@ const initApp = () => {
body: formData body: formData
}); });
const data = await res.json(); const data = await res.json();
generatedReport = data.report; generatedReport = data.relatorio || data.report;
generatedCriancas = data.criancas;
generatedTurma = data.turma || 'não informada';
generatedTags = data.tags || [];
showReportPreview(data); showReportPreview(data);
} catch (err) { } catch (err) {
console.error('Erro ao processar observação:', err); console.error('Erro ao processar observação:', err);
@@ -2080,7 +2086,22 @@ const initApp = () => {
hour: '2-digit', minute: '2-digit' hour: '2-digit', minute: '2-digit'
}); });
reportContent.innerHTML = marked.parse(generatedReport || 'Relatório não disponível.'); // Monta HTML com metadados
const criancasHtml = data.criancas && data.criancas.length
? `<div class="report-meta-item"><span class="meta-label">👧 Crianças:</span> ${data.criancas.join(', ')}</div>`
: `<div class="report-meta-item"><span class="meta-label">👧 Crianças:</span> <em>não identificadas</em></div>`;
const tagsHtml = data.tags && data.tags.length
? `<div class="report-meta-item"><span class="meta-label">🏷️ Tags:</span> ${data.tags.map(t => `<span class="tag-chip">${t}</span>`).join(' ')}</div>`
: '';
const turmaHtml = data.turma && data.turma !== 'não informada'
? `<div class="report-meta-item"><span class="meta-label">🏫 Turma:</span> ${data.turma}</div>`
: '';
reportContent.innerHTML = `
<div class="report-meta">${criancasHtml}${turmaHtml}${tagsHtml}</div>
<hr style="border-color: var(--border-light); margin: 12px 0;">
${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));
}; };
@@ -2103,7 +2124,12 @@ const initApp = () => {
const res = await fetch('/api/observacao/salvar', { const res = await fetch('/api/observacao/salvar', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ report: generatedReport }) body: JSON.stringify({
report: generatedReport,
criancas: generatedCriancas,
turma: generatedTurma,
tags: generatedTags
})
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
@@ -2145,6 +2171,151 @@ const initApp = () => {
if (e.target === recordModal) closeRecordModal(); if (e.target === recordModal) closeRecordModal();
}); });
} }
// ============================================================
// MODAL: MINHAS OBSERVAÇÕES
// ============================================================
const obsModal = document.getElementById('obsModal');
const btnObservacoes = document.getElementById('btnObservacoes');
const btnCloseObsModal = document.getElementById('btnCloseObsModal');
const obsList = document.getElementById('obsList');
const obsFilterMes = document.getElementById('obsFilterMes');
const obsFilterTag = document.getElementById('obsFilterTag');
const obsSearchCrianca = document.getElementById('obsSearchCrianca');
let allObservations = [];
// Abrir modal de observações
if (btnObservacoes) {
btnObservacoes.addEventListener('click', async () => {
obsModal.style.display = 'flex';
obsList.innerHTML = '<div class="obs-empty">Carregando...</div>';
try {
const res = await fetch('/api/observacoes');
const data = await res.json();
allObservations = data.observations || [];
populateMonthFilter();
renderObsList();
} catch (err) {
console.error('Erro ao carregar observações:', err);
obsList.innerHTML = '<div class="obs-empty">Erro ao carregar observações.</div>';
}
});
}
// Fechar modal
if (btnCloseObsModal) {
btnCloseObsModal.addEventListener('click', () => {
obsModal.style.display = 'none';
});
}
if (obsModal) {
obsModal.addEventListener('click', (e) => {
if (e.target === obsModal) obsModal.style.display = 'none';
});
}
// Filtros
if (obsFilterMes) obsFilterMes.addEventListener('change', renderObsList);
if (obsFilterTag) obsFilterTag.addEventListener('change', renderObsList);
if (obsSearchCrianca) obsSearchCrianca.addEventListener('input', renderObsList);
// Preenche select de meses
const populateMonthFilter = () => {
if (!obsFilterMes) return;
const months = [...new Set(allObservations.map(o => o.date?.slice(0, 7)))]
.filter(Boolean).sort().reverse();
obsFilterMes.innerHTML = '<option value="">Todos os meses</option>' +
months.map(m => `<option value="${m}">${m.split('-')[1]}/${m.split('-')[0]}</option>`).join('');
};
// Renderiza lista filtrada
const renderObsList = () => {
if (!obsList) return;
const mes = obsFilterMes?.value || '';
const tag = obsFilterTag?.value || '';
const search = obsSearchCrianca?.value.toLowerCase() || '';
const filtered = allObservations.filter(o => {
if (mes && !o.date?.startsWith(mes)) return false;
if (tag && !o.tags?.includes(tag)) return false;
if (search) {
const nomes = (o.criancas || []).join(' ').toLowerCase();
if (!nomes.includes(search) && !(o.preview || '').toLowerCase().includes(search)) return false;
}
return true;
});
if (filtered.length === 0) {
obsList.innerHTML = '<div class="obs-empty">Nenhuma observação encontrada.</div>';
return;
}
obsList.innerHTML = filtered.map(o => {
const tagsHtml = (o.tags || []).map(t => `<span class="obs-tag">${t}</span>`).join('');
const criancasHtml = (o.criancas || []).map(c => `<span class="obs-crianca">${c}</span>`).join('');
const dateFormatted = o.date ? o.date.split('-').reverse().join('/') : '';
return `
<div class="obs-item" data-filename="${o.filename}" data-yearmonth="${o.date?.slice(0, 7)}">
<div class="obs-item-header">
<strong>📋 ${dateFormatted}${o.time ? ' às ' + o.time : ''}</strong>
${o.turma && o.turma !== 'não informada' ? `<span class="obs-crianca">🏫 ${o.turma}</span>` : ''}
</div>
<div class="obs-item-preview">${o.preview || 'Sem prévia disponível.'}</div>
<div class="obs-item-footer">${tagsHtml}${criancasHtml}</div>
</div>
`;
}).join('');
// Click para abrir observação
obsList.querySelectorAll('.obs-item').forEach(item => {
item.addEventListener('click', async () => {
const ym = item.dataset.yearmonth;
const fn = item.dataset.filename;
if (!ym || !fn) return;
try {
const res = await fetch(`/api/observacoes/${ym}/${fn}`);
const text = await res.text();
showObsDetail(text);
} catch (err) {
console.error('Erro ao carregar observação:', err);
}
});
});
};
// Mostra detalhe da observação em modal
const showObsDetail = (mdText) => {
const modal = document.createElement('div');
modal.className = 'settings-modal';
modal.style.display = 'flex';
modal.innerHTML = `
<div class="settings-modal-content obs-modal-content" style="max-width: 680px;">
<div class="settings-modal-header">
<h3>📋 Observação</h3>
<button class="btn-close-modal" onclick="this.closest('.settings-modal').remove()">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div style="padding: 20px; overflow-y: auto; max-height: 70vh;">
<div class="obs-detail-content" style="color: var(--text-primary); line-height: 1.6;">
${marked.parse(mdText.replace(/^---[\s\S]*?---\n/, ''))}
</div>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button class="btn-save-report" onclick="navigator.clipboard.writeText(this.closest('.settings-modal').querySelector('.obs-detail-content').innerText).then(()=>alert('Copiado!'))">Copiar Texto</button>
<button class="btn-discard" onclick="this.closest('.settings-modal').remove()">Fechar</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
};
}; };
// Player de áudio personalizado global para as mídias geradas // Player de áudio personalizado global para as mídias geradas
+45
View File
@@ -65,6 +65,14 @@
<button class="tag-filter" data-tag="estudos">📚 Estudos</button> <button class="tag-filter" data-tag="estudos">📚 Estudos</button>
</div> </div>
<!-- Botão: Minhas Observações -->
<button class="btn-observacoes" id="btnObservacoes">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
<span>Minhas Observações</span>
</button>
<!-- Histórico de Conversas --> <!-- Histórico de Conversas -->
<div class="chat-history" id="chatHistory"> <div class="chat-history" id="chatHistory">
<!-- Histórico filtrado/busca --> <!-- Histórico filtrado/busca -->
@@ -386,6 +394,43 @@
</div> </div>
</div> </div>
<!-- Modal: Minhas Observações -->
<div id="obsModal" class="settings-modal" style="display: none;">
<div class="settings-modal-content obs-modal-content">
<div class="settings-modal-header">
<h3>📒 Minhas Observações</h3>
<button id="btnCloseObsModal" class="btn-close-modal">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="obs-modal-body">
<!-- Filtros -->
<div class="obs-filters" id="obsFilters">
<select id="obsFilterMes" class="obs-select">
<option value="">Todos os meses</option>
</select>
<select id="obsFilterTag" class="obs-select">
<option value="">Todas as tags</option>
<option value="social">Social</option>
<option value="cognitivo">Cognitivo</option>
<option value="motor">Motor</option>
<option value="linguagem">Linguagem</option>
<option value="emocional">Emocional</option>
<option value="colaborativo">Colaborativo</option>
<option value="autónomo">Autónomo</option>
</select>
<input type="text" id="obsSearchCrianca" class="obs-input" placeholder="Buscar por criança...">
</div>
<!-- Lista de observações -->
<div id="obsList" class="obs-list">
<div class="obs-empty">Carregando observações...</div>
</div>
</div>
</div>
</div>
<!-- Bibliotecas externas para renderizar markdown e blocos de código --> <!-- Bibliotecas externas para renderizar markdown e blocos de código -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
+171 -1
View File
@@ -2804,7 +2804,177 @@ code {
opacity: 0.9; opacity: 0.9;
} }
/* Animação fadeIn global */ /* Meta info do relatório na prévia */
.report-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
}
.report-meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
}
.meta-label {
color: var(--text-muted, #666);
font-weight: 500;
}
.tag-chip {
display: inline-block;
background: var(--brand-green, #10a37f);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
/* Botão Minhas Observações na sidebar */
.btn-observacoes {
display: flex;
align-items: center;
gap: 10px;
width: calc(100% - 40px);
margin: 0 auto 8px;
padding: 12px 16px;
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-light, #262626);
border-radius: 12px;
color: var(--text-secondary, #a3a3a3);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-observacoes:hover {
background: var(--brand-green, #10a37f);
color: white;
border-color: var(--brand-green, #10a37f);
}
.btn-observacoes svg {
width: 20px;
height: 20px;
flex-shrink: 0;
}
/* Modal de Observações */
.obs-modal-content {
max-width: 640px !important;
max-height: 85vh;
}
.obs-modal-body {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.obs-filters {
display: flex;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-light, #262626);
flex-wrap: wrap;
}
.obs-select, .obs-input {
flex: 1;
min-width: 120px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-light, #262626);
background: var(--bg-input, #121212);
color: var(--text-primary, #ffffff);
font-size: 0.85rem;
}
.obs-input {
flex: 1;
min-width: 160px;
}
.obs-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.obs-empty {
text-align: center;
color: var(--text-muted, #666);
padding: 40px 20px;
font-size: 0.9rem;
}
.obs-item {
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-light, #262626);
border-radius: 12px;
padding: 14px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.obs-item:hover {
border-color: var(--brand-green, #10a37f);
background: var(--bg-input, #121212);
}
.obs-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.obs-item-date {
font-size: 0.75rem;
color: var(--text-muted, #666);
}
.obs-item-preview {
font-size: 0.82rem;
color: var(--text-secondary, #a3a3a3);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.obs-item-footer {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.obs-tag {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
background: rgba(16, 163, 127, 0.2);
color: var(--brand-green, #10a37f);
}
.obs-crianca {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary, #a3a3a3);
}
/* FAB: Floating Action Button para gravação */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
+44 -21
View File
@@ -252,24 +252,32 @@ app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'
} }
// Gera relatório estruturado via GPT // 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. 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. 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:
REGRAS DO RELATÓRIO: {
- Use linguagem formal-pedagógica com calor humano "criancas": ["lista de nomes de crianças mencionadas ou 'null' se não identificar nenhuma"],
- Estruture em seções claras com_MARKDOWN "turma": "turma mencionada ou 'não informada'",
- Identifique crianças mencionadas, comportamentos, interações, conquistas, dificuldades "tags": ["tag1", "tag2"] (social, cognitivo, motor, linguagem, emocional,轮的哪个),
- Inclua sugestões de seguimento para o planejamento "relatorio": "...markdown estruturado do relatório pedagógico..."
- Foque em evidências observáveis, não interpretções subjetivas }
- Seja objetiva mas detalhista
TRANSCRIÇÃO DA OBSERVAÇÃO: REGRAS:
- Identifique crianças pelo nome próprio mencionado na fala
- 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:
""" """
${transcribedText} ${transcribedText}
""" """
Gerando o relatório...`; Retorne APENAS o JSON válido:`;
const reportRes = await fetch('https://openrouter.ai/api/v1/chat/completions', { const reportRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST', method: 'POST',
@@ -290,11 +298,29 @@ Gerando o relatório...`;
} }
const reportData = await reportRes.json(); const reportData = await reportRes.json();
const reportText = reportData.choices?.[0]?.message?.content || 'Relatório não pôde ser gerado.'; const rawContent = reportData.choices?.[0]?.message?.content || '';
// Parse JSON da resposta
let parsedData = { criancas: null, turma: 'não informada', tags: [], relatorio: rawContent };
try {
// Limpa markdown code blocks se houver
const cleanJson = rawContent.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const parsed = JSON.parse(cleanJson);
parsedData = {
criancas: parsed.criancas || null,
turma: parsed.turma || 'não informada',
tags: parsed.tags || [],
relatorio: parsed.relatorio || rawContent
};
} catch (parseErr) {
// Se não conseguir parsear JSON, retorna o texto como relatório
console.warn('Não foi possível parsear JSON do GPT, usando texto bruto');
parsedData.relatorio = rawContent;
}
res.json({ res.json({
transcribedText, transcribedText,
report: reportText ...parsedData
}); });
} catch (err) { } catch (err) {
@@ -306,7 +332,7 @@ Gerando o relatório...`;
// Rota: POST /api/observacao/salvar — salva relatório em .md // Rota: POST /api/observacao/salvar — salva relatório em .md
app.post('/api/observacao/salvar', requireAuth, (req, res) => { app.post('/api/observacao/salvar', requireAuth, (req, res) => {
const { report } = req.body; const { report, criancas, turma, tags } = req.body;
if (!report) return res.status(400).json({ error: 'Relatório vazio' }); if (!report) return res.status(400).json({ error: 'Relatório vazio' });
const now = new Date(); const now = new Date();
@@ -316,13 +342,7 @@ app.post('/api/observacao/salvar', requireAuth, (req, res) => {
const timestamp = now.toISOString(); const timestamp = now.toISOString();
// Monta markdown com frontmatter // Monta markdown com frontmatter
const mdContent = `--- const mdContent = `---\ndata: "${dateStr}"\nhora: "${timeStr}"\ntimestamp: "${timestamp}"\ncriancas: ${JSON.stringify(criancas)}\nturma: "${turma || 'não informada'}"\ntags: ${JSON.stringify(tags)}\n---\n\n${report}
data: "${dateStr}"
hora: "${timeStr}"
timestamp: "${timestamp}"
---
${report}
`; `;
const filename = `${dateStr}_${timeStr}.md`; const filename = `${dateStr}_${timeStr}.md`;
@@ -332,7 +352,7 @@ ${report}
const filePath = path.join(monthDir, filename); const filePath = path.join(monthDir, filename);
fs.writeFileSync(filePath, mdContent, 'utf8'); fs.writeFileSync(filePath, mdContent, 'utf8');
// Atualiza índice // Atualiza índice com metadados estruturados
const idx = readObsIndex(); const idx = readObsIndex();
idx.observations.unshift({ idx.observations.unshift({
id: Date.now(), id: Date.now(),
@@ -340,6 +360,9 @@ ${report}
date: dateStr, date: dateStr,
time: timeStr, time: timeStr,
timestamp, timestamp,
criancas: criancas || null,
turma: turma || 'não informada',
tags: tags || [],
preview: report.slice(0, 120) + '...' preview: report.slice(0, 120) + '...'
}); });
writeObsIndex(idx); writeObsIndex(idx);