Files
camila/server.js
T

3204 lines
140 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const multer = require('multer');
const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts');
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
});
// ============================================================================
// HELPER: chamada à API MiniMax (texto / classificação / reescrita de prompt)
// Substitui OpenRouter para todas as funções de TEXTO a partir de jun/2026.
// Imagem, música e vídeo continuam usando endpoints nativos MiniMax em /v1/
// ============================================================================
async function callMinimax({ messages, temperature = 0.5, max_tokens = 1500, system = null, model = null }) {
const baseUrl = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
const chosenModel = model || process.env.MINIMAX_MODEL || 'MiniMax-M3';
const finalMessages = system
? [{ role: 'system', content: system }, ...messages]
: messages;
const res = await fetch(`${baseUrl}/anthropic/v1/messages`, {
method: 'POST',
headers: {
'X-Api-Key': process.env.MINIMAX_API_KEY,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: chosenModel,
max_tokens,
temperature,
messages: finalMessages
})
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`MiniMax respondeu ${res.status}: ${txt}`);
}
const data = await res.json();
// Extrai só os blocos de texto (ignora thinking blocks)
const text = (data.content || [])
.filter(b => b.type === 'text')
.map(b => b.text)
.join('')
.trim();
return { text, raw: data };
}
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
app.use(cookieParser(process.env.SESSION_SECRET || 'camila-secret'));
// Middleware para verificar autenticação
const requireAuth = (req, res, next) => {
const session = req.signedCookies.camila_session || req.cookies.camila_session;
if (session === 'authenticated') {
next();
} else {
if (req.xhr || req.path.startsWith('/api/')) {
res.status(401).json({ error: 'Não autorizado' });
} else {
res.redirect('/login');
}
}
};
const fs = require('fs');
const { Pool } = require('pg');
const dbPool = new Pool({
connectionString: process.env.DATABASE_URL
});
const CONFIG_FILE_PATH = path.join(__dirname, 'agent_config.json');
const KNOWLEDGE_FILE_PATH = path.join(__dirname, 'conhecimento_adquirido.json');
const TEMPERATURE_PRESETS = {
tecnico: { value: 0.2, label: 'Técnico e Direto', description: 'Respostas curtas, objetivas, sem rodeios. Ideal para quando precisa só do essencial.' },
equilibrado: { value: 0.6, label: 'Equilibrado', description: 'Bom para uso geral — nem muito curto, nem muito longo.' },
detalhista: { value: 1.0, label: 'Detalhista e Falante', description: 'Respostas extensas, explicações completas,contextualização máxima. Ideal para estudo e revisão.' }
};
let agentConfigCache = {
agentName: "Kemily",
kemilyAvatarUrl: "assets/kemily.png",
camilaAvatarUrl: "assets/camila_prof.png",
temperaturePreset: "equilibrado"
};
function readAgentConfig() {
return agentConfigCache;
}
async function writeAgentConfig(config) {
agentConfigCache = {
agentName: config.agentName,
kemilyAvatarUrl: config.kemilyAvatarUrl,
camilaAvatarUrl: config.camilaAvatarUrl,
temperaturePreset: config.temperaturePreset || 'equilibrado'
};
try {
await dbPool.query(
`INSERT INTO escola.config (key, agent_name, kemily_avatar_url, camila_avatar_url, temperature_preset, updated_at)
VALUES ('current', $1, $2, $3, $4, NOW())
ON CONFLICT (key) DO UPDATE SET
agent_name = EXCLUDED.agent_name,
kemily_avatar_url = EXCLUDED.kemily_avatar_url,
camila_avatar_url = EXCLUDED.camila_avatar_url,
temperature_preset = EXCLUDED.temperature_preset,
updated_at = NOW();`,
[config.agentName, config.kemilyAvatarUrl, config.camilaAvatarUrl, config.temperaturePreset || 'equilibrado']
);
fs.writeFileSync(CONFIG_FILE_PATH, JSON.stringify(config, null, 2), 'utf8');
const botConfigDir = path.join(__dirname, '..', 'BotVPS', 'data');
if (fs.existsSync(botConfigDir)) {
fs.writeFileSync(path.join(botConfigDir, 'agent_config.json'), JSON.stringify(config, null, 2), 'utf8');
}
} catch (e) {
console.error('Erro ao salvar agent_config no banco:', e);
}
}
// Leitura/escrita do conhecimento adquirido
function readConhecimento() {
// Mantido apenas para compatibilidade de migração na inicialização
try {
if (fs.existsSync(KNOWLEDGE_FILE_PATH)) {
return JSON.parse(fs.readFileSync(KNOWLEDGE_FILE_PATH, 'utf8'));
}
} catch (e) {
console.error('Erro ao ler conhecimento_adquirido.json:', e);
}
return { facts: [], lastUpdated: null };
}
async function addKnowledgeFact(fact, source = 'conversa') {
try {
const res = await dbPool.query("SELECT content FROM escola.conhecimento;");
const exists = res.rows.some(f =>
f.content.toLowerCase().includes(fact.toLowerCase()) ||
fact.toLowerCase().includes(f.content.toLowerCase())
);
if (!exists) {
await dbPool.query(
"INSERT INTO escola.conhecimento (content, source, added_at) VALUES ($1, $2, NOW());",
[fact, source]
);
}
return !exists;
} catch (err) {
console.error('Erro ao adicionar fato no banco:', err);
return false;
}
}
async function removeKnowledgeFact(id) {
try {
await dbPool.query("DELETE FROM escola.conhecimento WHERE id = $1;", [id]);
} catch (err) {
console.error('Erro ao remover fato do banco:', err);
}
}
async function getKnowledgeAsText() {
try {
const res = await dbPool.query("SELECT content, source FROM escola.conhecimento ORDER BY added_at DESC;");
if (!res.rows.length) return '';
return '\n\n════════════════════════════════════════\nCONHECIMENTO ADQUIRIDO (Aprendido com a Camila):\n════════════════════════════════════════\n' +
res.rows.map(f => `[${f.source}] ${f.content}`).join('\n') +
'\n════════════════════════════════════════\n';
} catch (err) {
console.error('Erro ao ler conhecimento do banco:', err);
return '';
}
}
// Rota POST para sintetizar fala com MsEdgeTTS e retornar áudio
app.post('/api/tts', requireAuth, async (req, res) => {
const { text } = req.body;
if (!text) return res.status(400).json({ error: 'Texto é obrigatório' });
try {
const tts = new MsEdgeTTS();
await tts.setMetadata("pt-BR-FranciscaNeural", OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3);
const { audioStream } = tts.toStream(text);
res.setHeader('Content-Type', 'audio/mpeg');
audioStream.pipe(res);
audioStream.on('error', (err) => {
console.error('Erro na stream do MsEdgeTTS:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Erro ao processar áudio' });
}
});
} catch (error) {
console.error('Erro ao gerar TTS:', error);
if (!res.headersSent) {
res.status(500).json({ error: error.message || 'Erro ao gerar áudio' });
}
}
});
// Rota POST para gerar música customizada (Estúdio Musical)
app.post('/api/music/generate', requireAuth, async (req, res) => {
const { tema, voz, ritmo, duracao } = req.body;
if (!tema) {
return res.status(400).json({ error: 'Tema da música é obrigatório' });
}
try {
// 1. Determinar o prompt de estilo baseado nos inputs
let voiceDesc = 'female vocals, clear and sweet singing voice';
if (voz === 'homem') {
voiceDesc = 'male vocals, warm and gentle singing voice';
} else if (voz === 'crianca') {
voiceDesc = 'children vocals, sweet child singing voice';
}
let rhythmDesc = 'happy acoustic children song, simple melody, acoustic guitar';
if (ritmo === 'roda') {
rhythmDesc = 'traditional Brazilian cantiga de roda, folk children rhythm, acoustic guitar, percussion';
} else if (ritmo === 'pop') {
rhythmDesc = 'upbeat pop for children, modern synth, happy rhythm, claps';
} else if (ritmo === 'ninar') {
rhythmDesc = 'gentle lullaby for children, music box, soft strings, calm, sleeping mood';
} else if (ritmo === 'marcha') {
rhythmDesc = 'marching children rhythm, playful snare drums, acoustic guitar, brass accent';
}
const stylePrompt = `${rhythmDesc}, ${voiceDesc}, educational children song style, clear pronunciation, 100 BPM`;
// 2. Determinar o tamanho das letras baseado na duração (duracao)
let lengthInstructions = 'curta, de apenas 6 a 8 versos';
if (duracao === '1min') {
lengthInstructions = 'média, de 12 a 16 versos (com verso 1, refrão, verso 2, refrão)';
} else if (duracao === '2min') {
lengthInstructions = 'completa e longa, de 24 a 32 versos (com estrutura completa: verso 1, refrão, verso 2, refrão, ponte e refrão final)';
}
// 3. Chamar a IA (MiniMax-M3) para compor a letra
const lyricsPrompt = `Gere uma letra de cantiga escolar infantil sobre o tema: "${tema}".
A letra deve ser escrita para o público infantil (linguagem lúdica, alegre e educativa).
A estrutura de versos deve ser: ${lengthInstructions}.
Retorne APENAS a letra formatada em versos com quebras de linha. Não adicione notas, comentários ou títulos.`;
const aiResponse = await callMinimax({
system: "Você é um compositor pedagógico infantil que escreve letras de músicas educativas em português brasileiro. Retorne estritamente a letra da música, sem introduções ou observações extras.",
messages: [{ role: 'user', content: lyricsPrompt }],
temperature: 0.7,
max_tokens: 1000
});
const lyrics = aiResponse.text ? aiResponse.text.trim() : '';
if (!lyrics) {
throw new Error('Falha ao compor a letra da música.');
}
// 4. Chamar a API da MiniMax para gerar a música (canto + melodia)
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
const musicResp = await fetch(`${minmBase}/v1/music_generation`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'music-2.0',
prompt: stylePrompt,
lyrics: lyrics,
audio_setting: { sample_rate: 32000, bitrate: 128000, format: 'mp3' }
})
});
if (!musicResp.ok) {
const errText = await musicResp.text();
throw new Error(`MiniMax music-2.0 respondeu erro: ${errText}`);
}
const musicData = await musicResp.json();
const audioHex = musicData.data?.audio;
if (!audioHex) {
throw new Error('Nenhum dado de áudio retornado pela MiniMax.');
}
// 5. Salvar o arquivo localmente
const fs = require('fs');
const path = require('path');
const mediaDir = path.join(__dirname, 'public', 'generated-media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
const fileName = `estudio_musica_${Date.now()}.mp3`;
const filePath = path.join(mediaDir, fileName);
fs.writeFileSync(filePath, Buffer.from(audioHex, 'hex'));
const audioUrl = `/generated-media/${fileName}`;
// 6. Retornar dados para o frontend
res.json({
success: true,
lyrics: lyrics,
audioUrl: audioUrl
});
} catch (error) {
console.error('Erro no Estúdio Musical:', error);
res.status(500).json({ error: error.message || 'Ocorreu um erro ao gerar a música' });
}
});
// ============================================================
// ROTAS DO ESTÚDIO: FÁBRICA DE QUADRINHOS
// ============================================================
// 1. Gerar o roteiro (script) dos quadrinhos usando MiniMax-M3
app.post('/api/comics/generate-script', requireAuth, async (req, res) => {
const {
tema,
quantidade,
baloes,
proporcao,
personagens,
cenario,
existingPanels,
character_description,
continuationType // 'extend' ou 'new_story'
} = req.body;
if (!tema) {
return res.status(400).json({ error: 'O tema dos quadrinhos é obrigatório.' });
}
try {
let prompt = '';
if (existingPanels && Array.isArray(existingPanels) && existingPanels.length > 0) {
const startPanelNum = continuationType === 'extend' ? existingPanels.length + 1 : 1;
prompt = `Você está continuando ou se baseando em um projeto de história em quadrinhos existente.
DADOS DO PROJETO EXISTENTE:
- Descrição visual física e de estilo constante dos personagens (em inglês): "${character_description || ''}"
- Painéis já existentes (roteiro anterior):
${existingPanels.map(p => ` * Quadrinho ${p.panel_number || p.panelNumber}: [Visual] ${p.image_prompt || p.imagePrompt || 'N/A'} | [Fala/Legenda] ${p.dialogue}`).join('\n')}
INSTRUÇÕES DE GERAÇÃO:
Tipo de continuidade: ${continuationType === 'extend' ? 'Estender a mesma história (continuação cronológica direta, criando novos painéis sequenciais)' : 'Criar uma nova história separada (spin-off com os mesmos personagens, recomeçando do painel 1)'}.
Tema/Objetivo da nova fase: "${tema}".
Gere exatamente ${quantidade} novos quadrinhos.
Baloes de falas: ${baloes ? 'Sim, cada quadrinho deve conter um diálogo/fala extremamente conciso e curto, contendo no máximo 10 palavras no total.' : 'Não, a história é silenciosa, sem falas, apenas a descrição da cena'}.
Proporção visual: ${proporcao}.
Cenário/Local principal da nova fase: ${cenario}.
Gere um JSON perfeitamente válido contendo:
{
"title": "${continuationType === 'extend' ? 'Manter o título original ou propor um subtítulo condizente' : 'Novo Título da História'}",
"character_description": "${character_description || 'Descrição visual constante dos personagens em inglês'}",
"panels": [
{
"panel_number": ${startPanelNum},
"image_prompt": "Prompt visual detalhado em inglês para o gerador de imagens da MiniMax descrevendo a cena deste quadrinho. IMPORTANTE PARA CONSISTÊNCIA: Substitua TODOS os nomes de personagens por sua descrição física minuciosa em inglês (obtida de character_description) em cada quadrinho que aparecerem (ex: em vez de apenas escrever 'Lucas', escreva a descrição detalhada dele com roupas e cabelos específicos, etc). IMPORTANTE PARA TEXTO: Use enquadramento baixo/afastado (wide angle, medium shot, characters seen from a distance, low framing). O prompt DEVE conter a instrução explícita em inglês de deixar o terço superior (cerca de 30% do topo da imagem) totalmente livre de personagens ou elementos cruciais da cena, mantendo-o como espaço vazio/fundo limpo (ex: 'lower framing, characters positioned in the middle-to-lower section, leaving the top 30% of the image as empty space / simple clean background for text overlay'), para os balões de diálogo. O estilo deve ser de ilustração de livro infantil de alta qualidade, 3D Pixar, traço limpo.",
"dialogue": "Falas do quadrinho em português (ex: 'Lucas: Veja aquilo!') com NO MÁXIMO 10 palavras, ou narração curta caso não tenha balões."
},
...
]
}
Responda APENAS o JSON válido, sem qualquer texto explicativo antes ou depois.`;
} else {
prompt = `Crie uma história em quadrinhos pedagógica infantil com o tema: "${tema}".
A história deve conter exatamente ${quantidade} quadrinhos.
Baloes de falas: ${baloes ? 'Sim, cada quadrinho deve conter um diálogo/fala extremamente conciso e curto, contendo no máximo 10 palavras no total.' : 'Não, a história é silenciosa, sem falas, apenas a descrição da cena'}.
Proporção visual: ${proporcao}.
Personagens participantes: ${JSON.stringify(personagens)}.
Cenário/Local principal: ${cenario}.
Gere um JSON perfeitamente válido contendo:
{
"title": "Título da História",
"character_description": "Descrição visual constante dos personagens em inglês (para usar em geradores de imagens, detalhando roupas específicas, cores, características físicas de forma fixa para manter a consistência).",
"panels": [
{
"panel_number": 1,
"image_prompt": "Prompt visual detalhado em inglês para o gerador de imagens da MiniMax descrevendo a cena deste quadrinho. IMPORTANTE PARA CONSISTÊNCIA: Substitua TODOS os nomes de personagens por sua descrição física minuciosa em inglês (do character_description) em cada quadrinho que aparecerem (ex: não escreva apenas 'Mariana', escreva a descrição dela detalhando cabelo e roupas de forma fixa e idêntica em todos os quadrinhos). IMPORTANTE PARA TEXTO: Use enquadramento baixo/afastado (wide angle, medium shot, characters seen from a distance, low framing) e instrua em inglês a deixar o terço superior (30% do topo da imagem) totalmente livre de personagens ou elementos cruciais (ex: 'lower framing, characters positioned in the middle-to-lower section, leaving the top 30% of the image as empty space / simple clean background for text overlay'), para os balões de diálogo. O estilo deve ser de ilustração de livro infantil de alta qualidade, 3D Pixar, traço limpo.",
"dialogue": "Falas do quadrinho em português (ex: 'Lucas: Veja aquilo!') com NO MÁXIMO 10 palavras, ou narração curta caso não tenha balões."
},
...
]
}
Responda APENAS o JSON válido, sem qualquer texto explicativo antes ou depois.`;
}
const r = await callMinimax({
system: "Você é um roteirista pedagógico especialista em histórias em quadrinhos infantis para educação básica. Você responde apenas com estruturas JSON válidas.",
messages: [{ role: 'user', content: prompt }],
temperature: 0.3,
max_tokens: 3000
});
let jsonText = r.text;
let scriptData;
try {
const firstBrace = jsonText.indexOf('{');
const lastBrace = jsonText.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1) {
const jsonString = jsonText.substring(firstBrace, lastBrace + 1);
scriptData = JSON.parse(jsonString);
} else {
jsonText = jsonText.replace(/```json/gi, '').replace(/```/g, '').trim();
scriptData = JSON.parse(jsonText);
}
} catch (parseErr) {
console.error('Falha ao parsear JSON do roteiro de quadrinhos. Resposta original:', jsonText);
throw new Error('Formato de resposta inválido retornado pela IA: ' + parseErr.message);
}
res.json(scriptData);
} catch (error) {
console.error('Erro ao gerar roteiro dos quadrinhos:', error);
res.status(500).json({ error: 'Erro ao compor a história em quadrinhos: ' + error.message });
}
});
// 2. Gerar uma imagem individual para um quadrinho usando MiniMax image-01
app.post('/api/comics/generate-frame', requireAuth, async (req, res) => {
const { prompt } = req.body;
if (!prompt) {
return res.status(400).json({ error: 'O prompt de imagem é obrigatório.' });
}
try {
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
const genResp = await fetch(`${minmBase}/v1/image_generation`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'image-01',
prompt: prompt,
n: 1
})
});
if (!genResp.ok) {
const errText = await genResp.text();
throw new Error(`MiniMax image-01 respondeu erro: ${errText}`);
}
const genData = await genResp.json();
const imageUrl = genData.data?.image_urls?.[0];
if (!imageUrl) {
throw new Error('Nenhuma URL de imagem retornada pela MiniMax.');
}
// Baixar e salvar localmente
const fs = require('fs');
const path = require('path');
const mediaDir = path.join(__dirname, 'public', 'generated-media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
const imgFetch = await fetch(imageUrl);
if (!imgFetch.ok) {
throw new Error(`Falha ao baixar imagem: ${imgFetch.statusText}`);
}
const imgBuffer = Buffer.from(await imgFetch.arrayBuffer());
const fileName = `quadrinho_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`;
const filePath = path.join(mediaDir, fileName);
fs.writeFileSync(filePath, imgBuffer);
res.json({ imageUrl: `/generated-media/${fileName}` });
} catch (error) {
console.error('Erro ao gerar quadrinho:', error);
res.status(500).json({ error: error.message || 'Erro ao gerar ilustração do painel' });
}
});
// 3. Compilar a apresentação de vídeo usando FFmpeg a partir dos painéis de quadrinhos
app.post('/api/comics/generate-video', requireAuth, async (req, res) => {
const { frames, musicSelection, frameDuration } = req.body;
if (!frames || !Array.isArray(frames) || frames.length === 0) {
return res.status(400).json({ error: 'Nenhum painel enviado para geração de vídeo.' });
}
const duration = frameDuration ? parseInt(frameDuration) : 5;
const musicFile = musicSelection || 'alegre';
try {
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
const mediaDir = path.join(__dirname, 'public', 'generated-media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
// Mapear trilha musical
let bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'alegre.mp3');
if (musicFile === 'calma') {
bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'calma.mp3');
} else if (musicFile === 'aventura') {
bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'aventura.mp3');
}
// Criar arquivo de input para o FFmpeg Concat
const tempFileId = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const concatFilePath = path.join(__dirname, `concat_${tempFileId}.txt`);
let concatContent = '';
frames.forEach(frame => {
const localFilePath = path.join(__dirname, 'public', frame.imageUrl);
concatContent += `file '${localFilePath}'\nduration ${duration}\n`;
});
// O concat do FFmpeg requer repetir o último arquivo no final
const lastLocalFilePath = path.join(__dirname, 'public', frames[frames.length - 1].imageUrl);
concatContent += `file '${lastLocalFilePath}'\n`;
fs.writeFileSync(concatFilePath, concatContent);
const outputFileName = `video_quadrinhos_${tempFileId}.mp4`;
const outputFilePath = path.join(mediaDir, outputFileName);
const totalDuration = frames.length * duration;
// Comando FFmpeg: concatena as imagens, adiciona o áudio, ajusta o codec para h264/aac e encerra na duração final
const ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${bgMusicPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest -t ${totalDuration} "${outputFilePath}"`;
console.log('[FFmpeg] Executando comando de vídeo:', ffmpegCommand);
exec(ffmpegCommand, (error, stdout, stderr) => {
// Remove o arquivo concat temporário
try {
fs.unlinkSync(concatFilePath);
} catch (e) {
console.error('Falha ao remover arquivo concat temporário:', e);
}
if (error) {
console.error('Erro ao processar FFmpeg:', error, stderr);
return res.status(500).json({ error: 'Erro ao gerar o vídeo da apresentação. Como o servidor foi atualizado e reiniciado recentemente, por favor crie/gere a história em quadrinhos novamente para recriar as imagens temporárias antes de exportar o vídeo.' });
}
res.json({ videoUrl: `/generated-media/${outputFileName}` });
});
} catch (error) {
console.error('Erro ao compilar vídeo de apresentação:', error);
res.status(500).json({ error: 'Erro ao gerar o vídeo: ' + error.message });
}
});
// --- ROTAS DE PROJETOS DE HQ (SALVAR, LISTAR, CARREGAR, EXCLUIR) ---
// 1. Listar projetos
app.get('/api/comics/projects', requireAuth, async (req, res) => {
const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000';
try {
const result = await dbPool.query(
'SELECT id, titulo, tema, cenario, proporcao, character_description_global, character_description_english, created_at, updated_at FROM escola.projetos_comics WHERE usuario_id = $1 ORDER BY updated_at DESC;',
[usuarioId]
);
res.json(result.rows);
} catch (error) {
console.error('Erro ao listar projetos de quadrinhos:', error);
res.status(500).json({ error: 'Erro ao listar projetos de quadrinhos.' });
}
});
// 2. Carregar projeto específico
app.get('/api/comics/projects/:id', requireAuth, async (req, res) => {
const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000';
const { id } = req.params;
try {
const projectRes = await dbPool.query(
'SELECT * FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2;',
[id, usuarioId]
);
if (projectRes.rows.length === 0) {
return res.status(404).json({ error: 'Projeto não encontrado.' });
}
const panelsRes = await dbPool.query(
'SELECT id, panel_number, image_url, image_prompt, dialogue FROM escola.comics_panels WHERE projeto_id = $1 ORDER BY panel_number ASC;',
[id]
);
res.json({
project: projectRes.rows[0],
panels: panelsRes.rows
});
} catch (error) {
console.error('Erro ao obter projeto de quadrinhos:', error);
res.status(500).json({ error: 'Erro ao obter projeto de quadrinhos.' });
}
});
// 3. Salvar / Atualizar projeto
app.post('/api/comics/projects', requireAuth, async (req, res) => {
const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000';
const { id, titulo, tema, cenario, proporcao, character_description_global, character_description_english, panels } = req.body;
if (!titulo) {
return res.status(400).json({ error: 'O título do projeto é obrigatório.' });
}
const client = await dbPool.connect();
try {
await client.query('BEGIN');
let projetoId = id;
if (projetoId) {
// Verificar propriedade
const check = await client.query(
'SELECT id FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2;',
[projetoId, usuarioId]
);
if (check.rows.length === 0) {
throw new Error('Projeto não encontrado para edição.');
}
// Atualizar metadados
await client.query(
`UPDATE escola.projetos_comics
SET titulo = $1, tema = $2, cenario = $3, proporcao = $4, character_description_global = $5, character_description_english = $6, updated_at = NOW()
WHERE id = $7;`,
[titulo, tema, cenario, proporcao, character_description_global, character_description_english, projetoId]
);
} else {
// Criar novo projeto
const insertProj = await client.query(
`INSERT INTO escola.projetos_comics (usuario_id, titulo, tema, cenario, proporcao, character_description_global, character_description_english)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id;`,
[usuarioId, titulo, tema, cenario, proporcao, character_description_global, character_description_english]
);
projetoId = insertProj.rows[0].id;
}
// Atualizar painéis: remover antigos se existiam e salvar novos
await client.query('DELETE FROM escola.comics_panels WHERE projeto_id = $1;', [projetoId]);
if (panels && Array.isArray(panels)) {
for (const p of panels) {
await client.query(
`INSERT INTO escola.comics_panels (projeto_id, panel_number, image_url, image_prompt, dialogue)
VALUES ($1, $2, $3, $4, $5);`,
[projetoId, p.panel_number, p.image_url, p.image_prompt, p.dialogue]
);
}
}
await client.query('COMMIT');
res.json({ success: true, id: projetoId, message: 'Projeto salvo com sucesso!' });
} catch (error) {
await client.query('ROLLBACK');
console.error('Erro ao salvar projeto de quadrinhos:', error);
res.status(500).json({ error: 'Erro ao salvar projeto: ' + error.message });
} finally {
client.release();
}
});
// 4. Excluir projeto
app.delete('/api/comics/projects/:id', requireAuth, async (req, res) => {
const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000';
const { id } = req.params;
try {
const result = await dbPool.query(
'DELETE FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2 RETURNING id;',
[id, usuarioId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Projeto não encontrado ou sem permissão para exclusão.' });
}
res.json({ success: true, message: 'Projeto excluído com sucesso!' });
} catch (error) {
console.error('Erro ao excluir projeto de quadrinhos:', error);
res.status(500).json({ error: 'Erro ao excluir projeto de quadrinhos.' });
}
});
// Rota GET para obter configurações do agente
app.get('/api/agent-config', requireAuth, (req, res) => {
const config = readAgentConfig();
res.json(config);
});
// Rota POST para atualizar configurações do agente
app.post('/api/agent-config', requireAuth, async (req, res) => {
const { agentName, kemilyAvatarUrl, camilaAvatarUrl, temperaturePreset } = req.body;
if (!agentName) {
return res.status(400).json({ error: 'Nome do agente é obrigatório' });
}
const config = {
agentName: agentName.trim(),
kemilyAvatarUrl: kemilyAvatarUrl || "assets/kemily.png",
camilaAvatarUrl: camilaAvatarUrl || "assets/camila_prof.png",
temperaturePreset: temperaturePreset || 'equilibrado'
};
await writeAgentConfig(config);
res.json({ success: true, config });
});
// Rotas de conhecimento adquirido
app.get('/api/conhecimento', requireAuth, async (req, res) => {
try {
const dbRes = await dbPool.query("SELECT id, content, source, added_at as \"addedAt\" FROM escola.conhecimento ORDER BY added_at DESC;");
res.json({ facts: dbRes.rows });
} catch (err) {
console.error('Erro ao listar conhecimento:', err);
res.status(500).json({ error: err.message });
}
});
app.post('/api/conhecimento', requireAuth, async (req, res) => {
const { fact, source } = req.body;
if (!fact) return res.status(400).json({ error: 'Fato é obrigatório' });
const added = await addKnowledgeFact(fact, source || 'conversa');
res.json({ success: true, added });
});
app.delete('/api/conhecimento/:id', requireAuth, async (req, res) => {
await removeKnowledgeFact(parseInt(req.params.id));
res.json({ success: true });
});
app.put('/api/conhecimento/:id', requireAuth, async (req, res) => {
const { content } = req.body;
if (!content) return res.status(400).json({ error: 'Conteúdo é obrigatório' });
try {
const id = parseInt(req.params.id);
await dbPool.query("UPDATE escola.conhecimento SET content = $1 WHERE id = $2;", [content, id]);
res.json({ success: true });
} catch (err) {
console.error('Erro ao atualizar conhecimento:', err);
res.status(500).json({ error: err.message });
}
});
// Analisar mensagem final e extrair conhecimento aprendido (chamado pelo frontend)
app.post('/api/conhecimento/extrair', requireAuth, async (req, res) => {
const { message } = req.body;
if (!message) return res.status(400).json({ error: 'Mensagem é obrigatória' });
const knowledgeRegex = /\[KNOWLEDGE:\s*([^\]]+)\]/gi;
const facts = [];
let match;
while ((match = knowledgeRegex.exec(message)) !== null) {
const fact = match[1].trim();
if (fact && fact.length > 3) {
const added = await addKnowledgeFact(fact, 'conversa');
if (added) facts.push(fact);
}
}
res.json({ success: true, factsFound: facts.length, facts });
});
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 (mantido apenas para fins de migração inicial)
function readObsIndex() {
if (!fs.existsSync(OBS_INDEX_FILE)) return { observations: [] };
try {
return JSON.parse(fs.readFileSync(OBS_INDEX_FILE, 'utf8'));
} catch { return { observations: [] }; }
}
function writeObsIndex(idx) {
try {
fs.writeFileSync(OBS_INDEX_FILE, JSON.stringify(idx, null, 2), 'utf8');
} catch (err) {
console.error('Erro ao escrever index local:', err);
}
}
// Inicializar banco de dados e migrar dados se estiver vazio
async function initDatabase() {
try {
// Garantir extensões UUID no banco
try {
await dbPool.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
await dbPool.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";');
} catch (extErr) {
console.warn('[Database Init] Aviso ao habilitar extensões UUID (pode requerer privilégios de superusuário):', extErr.message);
}
// 0. Estruturar banco de dados para Multi-Tenancy (Individualização por Professora)
// Garantir que as tabelas existentes tenham a coluna usuario_id
await dbPool.query(`
ALTER TABLE escola.modelos ADD COLUMN IF NOT EXISTS usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
ALTER TABLE escola.observacoes ADD COLUMN IF NOT EXISTS usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
ALTER TABLE escola.conhecimento ADD COLUMN IF NOT EXISTS usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
ALTER TABLE escola.config ADD COLUMN IF NOT EXISTS usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
`);
// Criar tabelas novas de conversas (histórico de chat) e criações (quadrinhos) com usuario_id
await dbPool.query(`
-- Conversas
CREATE TABLE IF NOT EXISTS escola.conversas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
titulo VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Mensagens
CREATE TABLE IF NOT EXISTS escola.mensagens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversa_id UUID REFERENCES escola.conversas(id) ON DELETE CASCADE,
sender VARCHAR(50) NOT NULL, -- 'user' ou 'assistant'
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Projetos de HQ (Fábrica de Quadrinhos)
CREATE TABLE IF NOT EXISTS escola.projetos_comics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
titulo VARCHAR(255) NOT NULL,
tema TEXT,
cenario TEXT,
proporcao VARCHAR(10) DEFAULT '16:9',
character_description_global TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Painéis individuais de cada HQ
CREATE TABLE IF NOT EXISTS escola.comics_panels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
projeto_id UUID REFERENCES escola.projetos_comics(id) ON DELETE CASCADE,
panel_number INTEGER NOT NULL,
image_url TEXT NOT NULL,
image_prompt TEXT,
dialogue TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Biblioteca de personagens persistentes para reuso
CREATE TABLE IF NOT EXISTS escola.comics_characters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
nome VARCHAR(100) NOT NULL,
descricao_fisica_en TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`);
// Garantir que a coluna character_description_english exista na tabela projetos_comics
await dbPool.query(`ALTER TABLE escola.projetos_comics ADD COLUMN IF NOT EXISTS character_description_english TEXT;`);
// 0.1. Garantir que a tabela de modelos exista e tenha dados padrão
await dbPool.query(`
CREATE TABLE IF NOT EXISTS escola.modelos (
id SERIAL PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
finalidade TEXT,
periodicidade VARCHAR(100),
estrutura TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`);
const checkModelos = await dbPool.query("SELECT COUNT(*) FROM escola.modelos;");
if (parseInt(checkModelos.rows[0].count) === 0) {
console.log('[Database Init] Inserindo modelos/templates padrão de relatório...');
await dbPool.query(`
INSERT INTO escola.modelos (nome, finalidade, periodicidade, estrutura) VALUES
($1, $2, $3, $4),
($5, $6, $7, $8);
`, [
"Relatório Semanal de Desenvolvimento",
"Acompanhar a evolução cognitiva e social das crianças semanalmente",
"Semanal",
`# Relatório Semanal de Desenvolvimento Pedagógico\n\n## 1. Aspectos Cognitivos e Linguagem\n- Como a criança interagiu com os materiais e desafios propostos esta semana.\n- Expressão verbal, argumentação e participação em rodas de conversa.\n\n## 2. Aspectos Socioemocionais e Autonomia\n- Colaboração com os colegas e resolução de pequenos conflitos.\n- Nível de independência na realização das atividades diárias.\n\n## 3. Aspectos Motores e Brincar\n- Coordenação motora ampla e fina durante as brincadeiras e atividades direcionadas.`,
"Avaliação Semestral Individual",
"Avaliar o progresso do aluno combinando uma nova introdução global gerada a partir das atividades do semestre com a narrativa individual de desenvolvimento.",
"Semestral",
`O relatório final deve ser gerado sem cabeçalhos institucionais ou títulos de seções (como "I. O Eu..."), contendo apenas texto corrido e estruturado em parágrafos pedagógicos formais e acolhedores no estilo da Professora Camila. O relatório deve ser composto por duas partes principais integradas:\n\nPARTE 1: INTRODUÇÃO GERAL E CONTEXTUALIZAÇÃO COLETIVA (Texto Comum da Turma)\n(Instrução para a IA: Com base nas vivências, projetos e dinâmicas coletivas que aparecem no histórico de observações, redija um texto inédito de 4 a 5 parágrafos descrevendo o trabalho pedagógico coletivo do semestre. Não cite nomes de crianças nesta parte. Siga rigorosamente a estrutura de parágrafos abaixo):\n- Parágrafo 1 — Planejamento Pedagógico & Objetivos Gerais da Turma: Apresente os focos pedagógicos do período (desenvolvimento físico, cognitivo, afetivo/emocional) e o trabalho com a convivência harmoniosa, organização dos espaços/materiais e os combinados de rotina.\n- Parágrafo 2 — Vivências Lúdicas e Simbolismo: Descreva como as brincadeiras e interações simbólicas (faz de conta) serviram para as crianças compartilharem emoções, resolverem conflitos, desenvolverem a empatia, a linguagem e o raciocínio lógico.\n- Parágrafo 3 — Contato com a Cultura Escrita: Descreva o contato das crianças com o mundo letrado e a função social da escrita por meio do acesso a diferentes portadores e gêneros textuais (livros, cartazes, listas, receitas, canções, etc.).\n- Parágrafo 4 — Projeto Temático do Semestre: Detalhe o principal projeto de pesquisa da turma no semestre (ex: Identidade e Diversidade, plantio, etc.), descrevendo as investigações, atividades práticas realizadas e os registros/painéis construídos coletivamente.\n- Parágrafo 5 — Corporeidade, Jogos e Desportividade: Descreva o trabalho com jogos (de tabuleiro, confeccionados com recicláveis) e atividades motoras (circuitos, cantigas de roda, parlendas, corda), destacando o estímulo à atenção, cooperação, agilidade e respeito a regras de convivência.\n\n---\n\nPARTE 2: DESENVOLVIMENTO INDIVIDUAL (Texto Personalizado do Aluno)\n(Instrução para a IA: Redija na sequência um texto narrativo e contínuo, focado exclusivamente no aluno específico, baseado nas observações desse estudante. Adote como inspiração de estilo, tom e estrutura os dois exemplos reais abaixo):\n\nEXEMPLOS REAIS DE INSPIRAÇÃO (Alice e Theo):\nExemplo 1:\n"Alice apresentou muitos avanços neste período. Demonstra muita autonomia nas atividades de rotina, organizando seus pertences pessoais, bem como os brinquedos de uso coletivo após o término das vivências lúdicas. Na hora da alimentação, a aluna demonstra segurança ao servir-se e porcionar sua comida sem desperdícios. No parque, interage alegremente com os colegas, preferindo brincar com potes e pazinhas na areia, demonstrando grande companheirismo e compartilhando vivências com seus amigos, em especial com a Mirella e o Natan. Apresenta ótimo desenvolvimento motor, demonstrando equilíbrio ao pedalar os triciclos, participando de gincanas e realizando circuitos com destreza. Na roda de conversa, expressa-se com clareza e demonstra interesse pela leitura e contação de histórias, folheando livros individualmente. Demonstra facilidade em seguir regras e combinados coletivos."\n\nExemplo 2:\n"Theo apresentou muitos avanços neste período. No parque, gosta de brincar com brinquedos de rodas e na areia com seus amigos, em especial o Izack, o Gabriel e o Murilo, interagindo em brincadeiras simbólicas como o jogo do \'zumbi\'. Demonstra bom desenvolvimento motor amplo, realizando circuitos de equilíbrio e gincanas com agilidade. Nas atividades de expressão oral e corporal, participa de cantigas de roda e recitações. Theo demonstra grande interesse pela leitura e pelos livros, solicitando a contação de histórias com frequência. Constrói belas estruturas tridimensionais com blocos e lego, compartilhando brinquedos após intervenção do adulto. Apresenta boa autonomia na alimentação, porcionando sua refeição de forma consciente. Demonstra evolução na organização e no cumprimento de combinados de convivência na roda e na fila, necessitando ocasionalmente do direcionamento de um adulto."\n\nDIRETRIZES DE CONTRUÇÃO DO TEXTO DO ALUNO:\n- Início Obrigatório: Comece o primeiro parágrafo exatamente com a frase: "[Nome do Aluno] apresentou muitos avanços neste período." (Substitua pelo nome do aluno avaliado).\n- Corpo da narrativa: Integre de forma orgânica, fluida e sem subtítulos ou tópicos:\n 1. Autonomia na rotina diária (organização de materiais individuais/coletivos e porcionamento alimentar na escola sem desperdício).\n 2. Brincadeiras e interações sociais (preferências no parque/areia, identificando os melhores amigos entre aspas, ex: "a Mirella").\n 3. Desenvolvimento motor amplo e fino (pular cordas, pedalar triciclos, circuitos, gincanas) e o respeito aos combinados de organização (fila, roda).\n 4. Linguagem e oralidade (vocabulário, clareza verbal, participação em recitações/música e interesse em livros de histórias).\n 5. Criatividade nas atividades diversificadas (lego/blocos 3D, pinturas, desenhos de formas definidas).\n 6. Desafios/Pontos de atenção: Se houver, utilize sempre linguagem pedagógica atenuada (ex: "necessitando de orientação do adulto", "permanece o desafio de", "com intervenções constantes"). Nunca faça críticas diretas.\n- Fechamento Obrigatório: Finalize o relatório com o seguinte parágrafo isolado: "Espero que o/a [Nome do Aluno] continue se interessando bastante pela escola e avançando muito em suas aprendizagens no próximo ano." (Substitua pelo nome do aluno).`
]);
}
// 1. Sincronizar cache de configurações do agente ou migrar
const configRes = await dbPool.query("SELECT agent_name, kemily_avatar_url, camila_avatar_url, temperature_preset FROM escola.config WHERE key = 'current';");
if (configRes.rows.length > 0) {
agentConfigCache = {
agentName: configRes.rows[0].agent_name,
kemilyAvatarUrl: configRes.rows[0].kemily_avatar_url,
camilaAvatarUrl: configRes.rows[0].camila_avatar_url,
temperaturePreset: configRes.rows[0].temperature_preset
};
console.log('[Database Init] Configurações do agente carregadas do banco de dados:', agentConfigCache.agentName);
} else {
console.log('[Database Init] Migrando configurações do agente do arquivo local...');
let localConfig = {
agentName: "Kemily",
kemilyAvatarUrl: "assets/kemily.png",
camilaAvatarUrl: "assets/camila_prof.png",
temperaturePreset: "equilibrado"
};
try {
if (fs.existsSync(CONFIG_FILE_PATH)) {
localConfig = JSON.parse(fs.readFileSync(CONFIG_FILE_PATH, 'utf8'));
}
} catch (err) {
console.error('[Database Init] Erro ao ler agent_config.json local:', err.message);
}
agentConfigCache = localConfig;
await dbPool.query(
"INSERT INTO escola.config (key, agent_name, kemily_avatar_url, camila_avatar_url, temperature_preset) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key) DO NOTHING;",
['current', localConfig.agentName, localConfig.kemilyAvatarUrl, localConfig.camilaAvatarUrl, localConfig.temperaturePreset || 'equilibrado']
);
}
// 2. Verificar/Migrar fatos de conhecimento
const knowledgeRes = await dbPool.query("SELECT COUNT(*) FROM escola.conhecimento;");
if (parseInt(knowledgeRes.rows[0].count) === 0) {
console.log('[Database Init] Migrando conhecimento do arquivo local...');
let localKnowledge = { facts: [], lastUpdated: null };
try {
if (fs.existsSync(KNOWLEDGE_FILE_PATH)) {
localKnowledge = JSON.parse(fs.readFileSync(KNOWLEDGE_FILE_PATH, 'utf8'));
}
} catch (err) {
console.error('[Database Init] Erro ao ler conhecimento_adquirido.json local:', err.message);
}
for (const fact of localKnowledge.facts || []) {
await dbPool.query(
"INSERT INTO escola.conhecimento (content, source, added_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;",
[fact.content, fact.source, fact.addedAt]
);
}
}
// 3. Verificar/Migrar observações pedagógicas
const obsRes = await dbPool.query("SELECT COUNT(*) FROM escola.observacoes;");
if (parseInt(obsRes.rows[0].count) === 0) {
console.log('[Database Init] Migrando observações do arquivo local...');
let localIndex = { observations: [] };
try {
if (fs.existsSync(OBS_INDEX_FILE)) {
localIndex = JSON.parse(fs.readFileSync(OBS_INDEX_FILE, 'utf8'));
}
} catch (err) {
console.error('[Database Init] Erro ao ler index.json local:', err.message);
}
for (const obs of localIndex.observations || []) {
let reportContent = obs.preview;
try {
const yearMonth = obs.date.slice(0, 7);
const filePath = path.join(OBSERVACOES_DIR, yearMonth, obs.filename);
if (fs.existsSync(filePath)) {
const rawFile = fs.readFileSync(filePath, 'utf8');
const parts = rawFile.split('---\n');
if (parts.length >= 3) {
reportContent = parts.slice(2).join('---\n').trim();
} else {
reportContent = rawFile;
}
}
} catch (fileErr) {
console.error('[Database Init] Erro ao ler arquivo de relatório:', obs.filename, fileErr.message);
}
await dbPool.query(
"INSERT INTO escola.observacoes (id, filename, date, time, timestamp, criancas, turma, tags, report) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO NOTHING;",
[
obs.id,
obs.filename,
obs.date,
obs.time,
obs.timestamp,
JSON.stringify(obs.criancas),
obs.turma,
JSON.stringify(obs.tags),
reportContent
]
);
}
}
console.log('[Database Init] Inicialização e migração do banco de dados concluídas com sucesso!');
} catch (err) {
console.error('[Database Init] Erro crítico ao inicializar banco de dados:', err);
}
}
// 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' });
}
let ext = 'webm';
if (req.file.mimetype) {
if (req.file.mimetype.includes('mp4')) ext = 'mp4';
else if (req.file.mimetype.includes('ogg')) ext = 'ogg';
else if (req.file.mimetype.includes('wav')) ext = 'wav';
else if (req.file.mimetype.includes('mpeg')) ext = 'mp3';
}
try {
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', fileBlob, `audio.${ext}`);
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('OpenAI Whisper error:', errText);
throw new Error('Erro na transcrição via OpenAI');
}
transcribedText = await whisperRes.text();
} else if (process.env.GROQ_API_KEY) {
console.log('[Smart Transcription] Usando Groq Whisper...');
const whisperFormData = new FormData();
whisperFormData.append('file', fileBlob, `audio.${ext}`);
whisperFormData.append('model', 'whisper-large-v3');
whisperFormData.append('language', 'pt');
whisperFormData.append('response_format', 'text');
const whisperRes = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`
},
body: whisperFormData
});
if (!whisperRes.ok) {
const errText = await whisperRes.text();
console.error('Groq Whisper error:', errText);
throw new Error('Erro na transcrição via Groq');
}
transcribedText = await whisperRes.text();
} else {
throw new Error('Nenhuma chave de API configurada para transcrição de áudio (OPENAI_API_KEY ou GROQ_API_KEY).');
}
if (!transcribedText || transcribedText.trim().length < 2) {
return res.status(400).json({ error: 'O áudio não contém fala identificável.' });
}
res.json({ transcribedText });
} 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, colaborativo, autónomo),
"relatorio": "...markdown estruturado do relatório pedagógico..."
}
REGRAS:
- 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
OBSERVAÇÃO REGISTRADA:
"""
${transcribedText}
"""
Retorne APENAS o JSON válido:`;
let rawContent = '';
// Tentar MiniMax (M3) como primário
try {
console.log('[Smart Report] Tentando gerar relatório via MiniMax M3...');
const result = await callMinimax({
messages: [{ role: 'user', content: reportPrompt }],
temperature: 0.5,
max_tokens: 1500
});
rawContent = result.text;
} catch (err) {
console.error('[Smart Report] Erro de conexão com MiniMax:', err.message);
}
// Fallback para OpenRouter se MiniMax falhar
if (!rawContent && process.env.OPENROUTER_API_KEY) {
try {
console.log('[Smart Report] Usando OpenRouter como fallback...');
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) {
const reportData = await reportRes.json();
rawContent = reportData.choices?.[0]?.message?.content || '';
} else {
console.warn('[Smart Report] Falha no OpenRouter:', await reportRes.text());
}
} catch (err) {
console.error('[Smart Report] Erro de conexão com OpenRouter:', err.message);
}
}
// Fallback para Groq se OpenRouter falhar
if (!rawContent && process.env.GROQ_API_KEY) {
try {
console.log('[Smart Report] Usando Groq como fallback para gerar relatório...');
const reportRes = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.GROQ_MODEL || 'llama-3.3-70b-versatile',
messages: [{ role: 'user', content: reportPrompt }],
temperature: 0.5,
max_tokens: 1500
})
});
if (reportRes.ok) {
const reportData = await reportRes.json();
rawContent = reportData.choices?.[0]?.message?.content || '';
} else {
console.warn('[Smart Report] Falha no Groq:', await reportRes.text());
}
} catch (err) {
console.error('[Smart Report] Erro de conexão com Groq:', err.message);
}
}
// Fallback para Google Gemini se OpenRouter e Groq falharem
if (!rawContent && process.env.GOOGLE_AI_API_KEY) {
try {
console.log('[Smart Report] Usando Google Gemini como fallback para gerar relatório...');
const reportRes = await fetch('https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GOOGLE_AI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash',
messages: [{ role: 'user', content: reportPrompt }],
temperature: 0.5,
max_tokens: 1500
})
});
if (reportRes.ok) {
const reportData = await reportRes.json();
rawContent = reportData.choices?.[0]?.message?.content || '';
} else {
console.warn('[Smart Report] Falha no Google Gemini:', await reportRes.text());
}
} catch (err) {
console.error('[Smart Report] Erro de conexão com Google Gemini:', err.message);
}
}
// Fallback para MiniMax se OpenRouter, Groq e Google Gemini falharem
if (!rawContent && process.env.MINIMAX_API_KEY) {
try {
console.log('[Smart Report] Usando MiniMax-M2.7 como fallback para gerar relatório...');
const reportRes = await fetch('https://api.minimaxi.chat/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'MiniMax-M2.7',
messages: [{ role: 'user', content: reportPrompt }],
temperature: 0.5,
max_tokens: 1500
})
});
if (reportRes.ok) {
const reportData = await reportRes.json();
rawContent = reportData.choices?.[0]?.message?.content || '';
} else {
console.warn('[Smart Report] Falha no MiniMax:', await reportRes.text());
}
} catch (err) {
console.error('[Smart Report] Erro de conexão com MiniMax:', err.message);
}
}
if (!rawContent) {
throw new Error('Falha ao processar o relatório pedagógico em todos os provedores de IA (OpenRouter, Groq, Google Gemini e MiniMax-M2.7).');
}
// Parse JSON da resposta
let parsedData = { criancas: null, turma: 'não informada', tags: [], relatorio: rawContent };
try {
const firstBrace = rawContent.indexOf('{');
const lastBrace = rawContent.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1) {
const jsonString = rawContent.substring(firstBrace, lastBrace + 1);
const parsed = JSON.parse(jsonString);
parsedData = {
criancas: parsed.criancas || null,
turma: parsed.turma || 'não informada',
tags: parsed.tags || [],
relatorio: parsed.relatorio || rawContent
};
} else {
throw new Error('Nenhum JSON encontrado na resposta da IA');
}
} catch (parseErr) {
console.warn('Não foi possível parsear JSON da resposta da IA:', parseErr.message);
parsedData.relatorio = rawContent;
}
res.json(parsedData);
} catch (err) {
console.error('Erro ao analisar observação:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: POST /api/observacao/salvar — salva relatório em .md
app.post('/api/observacao/salvar', requireAuth, async (req, res) => {
const { report, criancas, turma, tags } = 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();
const id = Date.now();
const filename = `${dateStr}_${timeStr}.md`;
// Salvar no Banco (Supabase)
try {
await dbPool.query(
"INSERT INTO escola.observacoes (id, filename, date, time, timestamp, criancas, turma, tags, report) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);",
[id, filename, dateStr, timeStr, timestamp, JSON.stringify(criancas), turma || 'não informada', JSON.stringify(tags), report]
);
} catch (dbErr) {
console.error('Erro ao salvar observação no banco:', dbErr);
}
// Monta markdown com frontmatter
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}\n`;
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');
// Também mantemos a escrita no index.json local para fins de compatibilidade
try {
const idx = readObsIndex();
idx.observations.unshift({
id,
filename,
date: dateStr,
time: timeStr,
timestamp,
criancas: criancas || null,
turma: turma || 'não informada',
tags: tags || [],
preview: report.slice(0, 120) + '...'
});
writeObsIndex(idx);
} catch (idxErr) {
console.error('Erro ao salvar índice local:', idxErr);
}
res.json({ success: true, filename, id });
});
// ============================================================
// CRUD DE MODELOS DE RELATÓRIO (TEMPLATES) & COMPILAÇÃO
// ============================================================
// Listar todos os modelos
app.get('/api/modelos', requireAuth, async (req, res) => {
try {
const dbRes = await dbPool.query("SELECT * FROM escola.modelos ORDER BY nome ASC;");
res.json(dbRes.rows);
} catch (err) {
console.error('Erro ao buscar modelos:', err);
res.status(500).json({ error: err.message });
}
});
// Criar novo modelo
app.post('/api/modelos', requireAuth, async (req, res) => {
const { nome, finalidade, periodicidade, estrutura } = req.body;
if (!nome || !estrutura) return res.status(400).json({ error: 'Nome e estrutura são obrigatórios' });
try {
const dbRes = await dbPool.query(
"INSERT INTO escola.modelos (nome, finalidade, periodicidade, estrutura) VALUES ($1, $2, $3, $4) RETURNING *;",
[nome, finalidade, periodicidade, estrutura]
);
res.status(201).json(dbRes.rows[0]);
} catch (err) {
console.error('Erro ao criar modelo:', err);
res.status(500).json({ error: err.message });
}
});
// Atualizar modelo existente
app.put('/api/modelos/:id', requireAuth, async (req, res) => {
const { id } = req.params;
const { nome, finalidade, periodicidade, estrutura } = req.body;
if (!nome || !estrutura) return res.status(400).json({ error: 'Nome e estrutura são obrigatórios' });
try {
const dbRes = await dbPool.query(
"UPDATE escola.modelos SET nome = $1, finalidade = $2, periodicidade = $3, estrutura = $4 WHERE id = $5 RETURNING *;",
[nome, finalidade, periodicidade, estrutura, id]
);
if (dbRes.rows.length === 0) return res.status(404).json({ error: 'Modelo não encontrado' });
res.json(dbRes.rows[0]);
} catch (err) {
console.error('Erro ao atualizar modelo:', err);
res.status(500).json({ error: err.message });
}
});
// Excluir modelo
app.delete('/api/modelos/:id', requireAuth, async (req, res) => {
const { id } = req.params;
try {
const dbRes = await dbPool.query("DELETE FROM escola.modelos WHERE id = $1 RETURNING *;", [id]);
if (dbRes.rows.length === 0) return res.status(404).json({ error: 'Modelo não encontrado' });
res.json({ success: true, message: 'Modelo excluído com sucesso' });
} catch (err) {
console.error('Erro ao excluir modelo:', err);
res.status(500).json({ error: err.message });
}
});
// Obter lista única de crianças já observadas
app.get('/api/observacoes/criancas', requireAuth, async (req, res) => {
const { ano, turma } = req.query;
try {
let sql = `
SELECT DISTINCT jsonb_array_elements_text(criancas) as nome
FROM escola.observacoes
WHERE jsonb_typeof(criancas) = 'array'
`;
const params = [];
let pCount = 1;
if (ano) {
sql += ` AND substring(date from 1 for 4) = $${pCount}`;
params.push(ano);
pCount++;
}
if (turma) {
sql += ` AND turma = $${pCount}`;
params.push(turma);
pCount++;
}
sql += ` ORDER BY nome;`;
const dbRes = await dbPool.query(sql, params);
res.json(dbRes.rows.map(r => r.nome));
} catch (err) {
console.error('Erro ao buscar crianças únicas:', err);
res.status(500).json({ error: err.message });
}
});
// Obter metadados únicos para filtros (anos letivos e turmas)
app.get('/api/observacoes/metadata', requireAuth, async (req, res) => {
try {
const yearsRes = await dbPool.query(`
SELECT DISTINCT substring(date from 1 for 4) as ano
FROM escola.observacoes
WHERE date IS NOT NULL AND date != ''
ORDER BY ano DESC;
`);
const turmasRes = await dbPool.query(`
SELECT DISTINCT turma
FROM escola.observacoes
WHERE turma IS NOT NULL AND turma != ''
ORDER BY turma;
`);
res.json({
anos: yearsRes.rows.map(r => r.ano).filter(Boolean),
turmas: turmasRes.rows.map(r => r.turma).filter(Boolean)
});
} catch (err) {
console.error('Erro ao buscar metadados de observações:', err);
res.status(500).json({ error: err.message });
}
});
// Contar observações baseadas em filtros
app.get('/api/observacoes/contar', requireAuth, async (req, res) => {
const { crianca, dataInicio, dataFim, tags, ano, turma } = req.query;
try {
let sql = `SELECT COUNT(*) FROM escola.observacoes WHERE 1=1`;
const params = [];
let pCount = 1;
if (crianca && crianca !== 'todas') {
sql += ` AND (
(jsonb_typeof(criancas) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(criancas) AS c
WHERE LOWER(c) LIKE $${pCount}
))
OR jsonb_typeof(criancas) = 'null'
OR criancas IS NULL
)`;
params.push(`%${crianca.toLowerCase()}%`);
pCount++;
}
if (dataInicio) {
sql += ` AND date >= $${pCount}`;
params.push(dataInicio);
pCount++;
}
if (dataFim) {
sql += ` AND date <= $${pCount}`;
params.push(dataFim);
pCount++;
}
if (ano) {
sql += ` AND substring(date from 1 for 4) = $${pCount}`;
params.push(ano);
pCount++;
}
if (turma) {
sql += ` AND turma = $${pCount}`;
params.push(turma);
pCount++;
}
if (tags) {
const tagList = tags.split(',').map(t => t.trim().toLowerCase());
sql += ` AND jsonb_typeof(tags) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(tags) AS t
WHERE LOWER(t) = ANY($${pCount})
)`;
params.push(tagList);
pCount++;
}
const dbRes = await dbPool.query(sql, params);
res.json({ count: parseInt(dbRes.rows[0].count) });
} catch (err) {
console.error('Erro ao contar observações:', err);
res.status(500).json({ error: err.message });
}
});
// Gerar relatório consolidado com IA baseado em observações e um modelo
app.post('/api/modelos/gerar-relatorio', requireAuth, async (req, res) => {
const { crianca, dataInicio, dataFim, tags, modeloId, ano, turma } = req.body;
if (!modeloId) return res.status(400).json({ error: 'O ID do modelo é obrigatório' });
try {
// 1. Buscar o modelo/template
const templateRes = await dbPool.query("SELECT * FROM escola.modelos WHERE id = $1;", [modeloId]);
if (templateRes.rows.length === 0) return res.status(404).json({ error: 'Modelo de relatório não encontrado' });
const template = templateRes.rows[0];
// 2. Buscar as observações filtradas
let sql = `
SELECT date, time, report, criancas, tags
FROM escola.observacoes
WHERE 1=1
`;
const params = [];
let pCount = 1;
if (crianca && crianca !== 'todas') {
sql += ` AND (
(jsonb_typeof(criancas) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(criancas) AS c
WHERE LOWER(c) LIKE $${pCount}
))
OR jsonb_typeof(criancas) = 'null'
OR criancas IS NULL
)`;
params.push(`%${crianca.toLowerCase()}%`);
pCount++;
}
if (dataInicio) {
sql += ` AND date >= $${pCount}`;
params.push(dataInicio);
pCount++;
}
if (dataFim) {
sql += ` AND date <= $${pCount}`;
params.push(dataFim);
pCount++;
}
if (ano) {
sql += ` AND substring(date from 1 for 4) = $${pCount}`;
params.push(ano);
pCount++;
}
if (turma) {
sql += ` AND turma = $${pCount}`;
params.push(turma);
pCount++;
}
if (tags) {
const tagList = Array.isArray(tags) ? tags : [tags];
sql += ` AND jsonb_typeof(tags) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(tags) AS t
WHERE LOWER(t) = ANY($${pCount})
)`;
params.push(tagList.map(t => t.toLowerCase()));
pCount++;
}
sql += ` ORDER BY date ASC`;
const dbRes = await dbPool.query(sql, params);
const observations = dbRes.rows;
if (observations.length === 0) {
return res.status(400).json({ error: 'Nenhuma observação encontrada para os filtros selecionados.' });
}
// 3. Montar prompt para IA usando a estrutura do template
const combinedObsText = observations.map((o, idx) => {
const dateText = o.date ? `Data: ${o.date}` : '';
const tagsText = o.tags ? `Tags: ${Array.isArray(o.tags) ? o.tags.join(', ') : o.tags}` : '';
return `[Observação #${idx + 1} ${dateText} ${tagsText}]\n${o.report}`;
}).join('\n\n---\n\n');
const agentConfig = readAgentConfig();
const agentName = agentConfig.agentName || "Kemily";
const isGlobal = crianca === 'todas';
const targetStudentInfo = isGlobal
? ''
: `\nATENÇÃO CRÍTICA: A criança que está sendo avaliada e para quem você deve gerar o relatório individual é especificamente: "${crianca}". Todas as observações, avanços, preferências e conclusões descritas na PARTE 2 (Desenvolvimento Individual) devem referir-se exclusivamente a ${crianca}. Nunca confunda ${crianca} com outros colegas eventualmente citados nas observações (como Joãozinho, Vanderlei, etc.), e não atribua as ações deles a ela.\n`;
const systemPrompt = `Você é a ${agentName}, uma assistente pedagógica e especialista em educação infantil.
Seu objetivo é analisar o histórico de observações ${isGlobal ? 'da turma' : 'de uma criança'} e redigir um relatório pedagógico consolidado e profissional ${isGlobal ? 'globalizado da sala de aula / turma' : 'sobre o desenvolvimento individual desta criança'}.
${targetStudentInfo}
Você DEVE estruturar o relatório seguindo exatamente as seções e diretrizes fornecidas no modelo de relatório selecionado.
MODELO DE RELATÓRIO: "${template.nome}"
FINALIDADE: "${template.finalidade || 'Não informada'}"
PERIODICIDADE: "${template.periodicidade || 'Não informada'}"
ESTRUTURA/DIRETRIZES DO MODELO:
"""
${template.estrutura}
"""
HISTÓRICO DE OBSERVAÇÕES REGISTRADAS:
"""
${combinedObsText.slice(0, 8000)}
"""
Orientações adicionais:
- Redija o relatório em português brasileiro formal e adequado para pedagogia.
- Preencha cada seção da estrutura de forma rica e detalhada, baseando-se estritamente nos fatos reais descritos nas observações.
- Não invente acontecimentos que não estejam descritos nas observações, mas faça as conexões pedagógicas pertinentes de acordo com as evidências apresentadas.
- Retorne apenas o relatório em formato Markdown estruturado.`;
let generatedReport = '';
// Tenta MiniMax (M3) como primário
try {
console.log('[Compilar Relatório] Chamando MiniMax M3...');
const result = await callMinimax({
messages: [{ role: 'user', content: systemPrompt }],
temperature: 0.5,
max_tokens: 6000
});
generatedReport = result.text;
} catch (err) {
console.error('[Compilar Relatório] Erro no MiniMax:', err.message);
}
// Fallback para OpenRouter se MiniMax falhar
if (!generatedReport && process.env.OPENROUTER_API_KEY) {
try {
console.log('[Compilar Relatório] Fallback para OpenRouter...');
const response = 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: systemPrompt }],
temperature: 0.5,
max_tokens: 6000
})
});
if (response.ok) {
const data = await response.json();
generatedReport = data.choices?.[0]?.message?.content || '';
} else {
console.warn('[Compilar Relatório] Falha no OpenRouter status:', response.status);
}
} catch (err) {
console.error('[Compilar Relatório] Erro no OpenRouter:', err.message);
}
}
// Fallback para Google Gemini
if (!generatedReport && process.env.GOOGLE_AI_API_KEY) {
try {
console.log('[Compilar Relatório] Fallback para Google Gemini...');
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: systemPrompt }] }],
generationConfig: { temperature: 0.5, maxOutputTokens: 8192 }
})
});
if (response.ok) {
const data = await response.json();
generatedReport = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
}
} catch (err) {
console.error('[Compilar Relatório] Erro no Google Gemini:', err.message);
}
}
// Fallback para Groq
if (!generatedReport && process.env.GROQ_API_KEY) {
try {
console.log('[Compilar Relatório] Fallback para Groq...');
const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.GROQ_MODEL || 'llama-3.3-70b-versatile',
messages: [{ role: 'user', content: systemPrompt }],
temperature: 0.5,
max_tokens: 6000
})
});
if (response.ok) {
const data = await response.json();
generatedReport = data.choices?.[0]?.message?.content || '';
}
} catch (err) {
console.error('[Compilar Relatório] Erro no Groq:', err.message);
}
}
if (!generatedReport) {
throw new Error('Falha ao compilar relatório pedagógico com todos os provedores de IA.');
}
res.json({
report: generatedReport,
templateNome: template.nome,
crianca: crianca === 'todas' ? 'Sala de Aula (Relatório Global)' : (crianca || 'Todos os alunos'),
periodo: (dataInicio || '') + (dataInicio && dataFim ? ' a ' : '') + (dataFim || 'Todo o período'),
totalObservacoes: observations.length
});
} catch (err) {
console.error('Erro geral ao compilar relatório:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: GET /api/observacoes — lista todas as observações (índice)
app.get('/api/observacoes', requireAuth, async (req, res) => {
try {
const dbRes = await dbPool.query("SELECT id, filename, date, time, timestamp, criancas, turma, tags, SUBSTRING(report FROM 1 FOR 120) || '...' as preview FROM escola.observacoes ORDER BY timestamp DESC;");
res.json({ observations: dbRes.rows });
} catch (err) {
console.error('Erro ao listar observações:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: GET /api/observacoes/:yearMonth/:filename — busca relatório específico
app.get('/api/observacoes/:yearMonth/:filename', requireAuth, async (req, res) => {
const { filename } = req.params;
try {
const dbRes = await dbPool.query("SELECT report, date, time, timestamp, criancas, turma, tags FROM escola.observacoes WHERE filename = $1;", [filename]);
if (dbRes.rows.length === 0) {
return res.status(404).json({ error: 'Não encontrado' });
}
const obs = dbRes.rows[0];
const mdContent = `---\ndata: "${obs.date}"\nhora: "${obs.time}"\ntimestamp: "${obs.timestamp}"\ncriancas: ${JSON.stringify(obs.criancas)}\nturma: "${obs.turma}"\ntags: ${JSON.stringify(obs.tags)}\n---\n\n${obs.report}`;
res.setHeader('Content-Type', 'text/markdown');
res.send(mdContent);
} catch (err) {
console.error('Erro ao ler observação do banco:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: GET /api/observacoes/buscar — busca observações por criança e/ou período
app.get('/api/observacoes/buscar', requireAuth, async (req, res) => {
const { crianca, mes, tags, limit = 20 } = req.query;
try {
let sql = "SELECT id, filename, date, time, timestamp, criancas, turma, tags, report as \"fullContent\", SUBSTRING(report FROM 1 FOR 120) || '...' as preview FROM escola.observacoes WHERE 1=1";
const params = [];
let pCount = 1;
if (crianca) {
sql += ` AND jsonb_typeof(criancas) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(criancas) AS c
WHERE LOWER(c) LIKE $${pCount}
)`;
params.push(`%${crianca.toLowerCase()}%`);
pCount++;
}
if (mes) {
sql += ` AND date LIKE $${pCount}`;
params.push(`${mes}%`);
pCount++;
}
if (tags) {
const tagList = tags.split(',').map(t => t.trim().toLowerCase());
sql += ` AND jsonb_typeof(tags) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(tags) AS t
WHERE LOWER(t) = ANY($${pCount})
)`;
params.push(tagList);
pCount++;
}
sql += ` ORDER BY timestamp DESC LIMIT $${pCount}`;
params.push(parseInt(limit));
const dbRes = await dbPool.query(sql, params);
const totalRes = await dbPool.query("SELECT COUNT(*) FROM escola.observacoes;");
const totalCount = parseInt(totalRes.rows[0].count);
res.json({ observations: dbRes.rows, total: totalCount });
} catch (err) {
console.error('Erro ao buscar observações:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: GET /api/observacoes/resumo — compila observações em resumo para documentos
app.get('/api/observacoes/resumo', requireAuth, async (req, res) => {
const { crianca, mes } = req.query;
if (!crianca) return res.status(400).json({ error: 'Nome da criança é obrigatório' });
try {
let sql = `
SELECT date, time, report, tags, turma, SUBSTRING(report FROM 1 FOR 120) || '...' as preview
FROM escola.observacoes
WHERE jsonb_typeof(criancas) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(criancas) AS c
WHERE LOWER(c) LIKE $1
)
`;
const params = [`%${crianca.toLowerCase()}%`];
if (mes) {
sql += " AND date LIKE $2";
params.push(`${mes}%`);
}
sql += " ORDER BY timestamp DESC LIMIT 15";
const dbRes = await dbPool.query(sql, params);
if (dbRes.rows.length === 0) {
return res.json({ resumo: null, total: 0, message: `Nenhuma observação encontrada para "${crianca}"${mes ? ` em ${mes}` : ''}.` });
}
const observations = dbRes.rows;
const texts = observations.map(o => o.report).filter(Boolean);
const combined = texts.join('\n\n---\n\n');
const agentConfig = readAgentConfig();
const agentName = agentConfig.agentName || "Kemily";
const compilePrompt = `Você é a ${agentName}, assistente pedagógica. Compilar um RESUMO PEDAGÓGICO consolidado a partir de várias observações de uma criança.
CRIANÇA: ${crianca}
NÚMERO DE OBSERVAÇÕES: ${observations.length}
${mes ? `PERÍODO: ${mes}` : ''}
COLEÇÃO DE OBSERVAÇÕES:
"""
${combined.slice(0, 4000)}
"""
GERE UM RESUMO ESTRUTURADO COM:
## Perfil da Criança
[Padrões de comportamento, habilidades, desenvolvimento observado]
## Aspectos Sociais e Emocionais
[Interações com outras crianças, gestão emocional, socialização]
## Aspectos Cognitivos e de Aprendizagem
[Curiosidade, resolução de problemas, atenção, concentration]
## Aspectos Motores e de Linguagem
[Desenvolvimento motor, linguagem oral, comunicação]
## Pontos de Atenção
[Dificuldades, necessidades de suporte, áreas a desenvolver]
## Pontos Fortes
[Conquistas, habilidades desenvolvidas, aspectos positivos]
## Sugestões para Planejamento
[Recomendações para próximos passos, atividades sugeridas, seguimento]
-use linguagem formal-pedagógica
-Máximo 300 palavras
-Seja objetivo e baseado nas evidências das observações`;
const compileResult = await callMinimax({
messages: [{ role: 'user', content: compilePrompt }],
temperature: 0.4,
max_tokens: 800
});
const resumoText = compileResult.text;
res.json({
crianca,
mes: mes || 'todos',
total: observations.length,
datas: observations.map(o => o.date).filter(Boolean),
resumo: resumoText,
observations: observations.map(o => ({
date: o.date, time: o.time, tags: o.tags, turma: o.turma, preview: o.preview
}))
});
} catch (err) {
console.error('Erro ao compilar resumo:', err);
res.status(500).json({ error: 'Erro ao gerar resumo pedagógico' });
}
});
// Rota POST para upload de imagem em base64
app.post('/api/upload-avatar', requireAuth, (req, res) => {
const { avatarType, base64Data } = req.body;
if (!avatarType || !base64Data) {
return res.status(400).json({ error: 'Dados inválidos' });
}
const matches = base64Data.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
if (!matches || matches.length !== 3) {
return res.status(400).json({ error: 'Formato de imagem inválido' });
}
const imageBuffer = Buffer.from(matches[2], 'base64');
const fileExt = matches[1].split('/')[1] || 'png';
const fileName = `custom_${avatarType}_avatar_${Date.now()}.${fileExt}`;
// Garantir que a pasta public/assets existe
const assetsDir = path.join(__dirname, 'public', 'assets');
if (!fs.existsSync(assetsDir)) {
fs.mkdirSync(assetsDir, { recursive: true });
}
const filePath = path.join(assetsDir, fileName);
fs.writeFile(filePath, imageBuffer, (err) => {
if (err) {
console.error('Erro ao salvar avatar:', err);
return res.status(500).json({ error: 'Erro ao salvar arquivo' });
}
// Otimizar a imagem em segundo plano com Python
const { exec } = require('child_process');
exec(`python3 scratch/optimize_avatars.py "${filePath}"`, (pyErr) => {
if (pyErr) {
console.error('Erro ao otimizar o avatar enviado com Python:', pyErr);
}
return res.json({ success: true, url: `assets/${fileName}` });
});
});
});
// Rota de login (página)
app.get('/login', (req, res) => {
const session = req.signedCookies.camila_session || req.cookies.camila_session;
if (session === 'authenticated') {
res.redirect('/');
} else {
res.sendFile(path.join(__dirname, 'public', 'login.html'));
}
});
// API de status do modelo
app.get('/api/status', requireAuth, (req, res) => {
res.json({
model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano',
provider: 'openrouter',
minimax_available: !!process.env.MINIMAX_API_KEY,
timestamp: new Date().toISOString()
});
});
// API para Login
app.post('/api/login', (req, res) => {
const { password } = req.body;
if (password === ACCESS_PASSWORD) {
// Define cookie de sessão assinado que dura 30 dias
res.cookie('camila_session', 'authenticated', {
signed: true,
httpOnly: true,
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dias
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production'
});
res.json({ success: true });
} else {
res.status(401).json({ error: 'Senha incorreta' });
}
});
// API para Logout
app.post('/api/logout', (req, res) => {
res.clearCookie('camila_session');
res.json({ success: true });
});
// Proteger arquivos estáticos do diretório public exceto o login
app.use((req, res, next) => {
if (req.path === '/login' || req.path === '/login.html' || req.path === '/login.js' || req.path === '/style.css' || req.path.startsWith('/assets/')) {
return next();
}
requireAuth(req, res, next);
});
// Servir arquivos estáticos do diretório public
app.use(express.static(path.join(__dirname, 'public')));
// System prompt da Kemily (injetado em todas as conversas)
const KEMILY_SYSTEM_PROMPT = {
role: "system",
content: `Você é a Kemily, assistente virtual e extensionista digital da Profª Camila Martella Gasparini Reifonas.
═══════════════════════════════════════════════════
IDENTIDADE — Quem é a Camila (para que você possa falar dela com precisão)
═══════════════════════════════════════════════════
INFORMAÇÕES PESSOAIS:
- Nome completo: Camila Martella Gasparini Reifonas
- Profissão: Professora da Educação Infantil na rede municipal de São Bernardo do Campo, SP
SITUAÇÃO FUNCIONAL / CONTEXTO TEMPORAL:
- **Ano de 2024 (ano passado)**: Professora efetiva de turma fixa, atuava no Infantil III (turma de 4-5 anos) como professora titular da turma.
- **Ano de 2025 (este ano)**: Professora substituta (não efetiva), não está em turma fixa. Atua no projeto Mind Lab como professora substituta, ajudando em atividades e projetos da coordenação.
- Etapa de atuação atual: projeto Mind Lab / substituição na Educação Infantil
- Local de trabalho: EMEB Mariana Neves Interliche — Sala 7, Turma B (contexto 2024)
ESCOLA — EMEB MARIANA NEVES INTERLICHE:
- Tipo: Escola Municipal de Educação Infantil (e Ensino Fundamental)
- Diretora: Maria (conforme documentos da Camila)
- Coordenadoras: Sirlene e Fernanda (coordenação pedagógica)
- Localização: São Bernardo do Campo, SP — região do Grande ABC
REDE MUNICIPAL DE SÃO BERNARDO DO CAMPO:
- Secretaria Municipal de Educação (SME)
- Subsidiado por: BNCC, Proposta Curricular de SBC, documento orientador da rede
- Formadora de referência (2024): Formadora Márcia (formações sobre leitura e escrita)
- Coordenadora de área/volante: Professora Sirlene
**ATENÇÃO - COORDENAÇÃO 2025:**
- Márcia era a formadora de referência em 2024. Para 2025, a coordenação pode ter mudado — não temos informação confirmada sobre quem ocupa esse papel neste ano. Se não souber, diga "não tenho essa informação".
SUPERIORAS/DOCENTES DE REFERÊNCIA (2024):
- Gestão da escola: Maria (diretora)
- Coordenação pedagógica: Sirlene e Fernanda (coordenadoras)
- Formadora da rede: Márcia (2024)
- Professora referenciada para substituição: Professora com quem Camila substitui
QUANDO A CAMILA PERGUNTAR "QUEM É A CAMILA" OU "FALE SOBRE MIM":
Responda com orgulho e detalhes, usando informações dos documentos reais. Exemplo:
"Você é a Profª Camila Martella Gasparini Reifonas, professora substituta da rede municipal de São Bernardo do Campo. Atua no Infantil III, turma B, Sala 7 da EMEB Mariana Neves Interliche. Você é reconhecida pelo seu compromisso com o protagonismo infantil, a escuta ativa e o desenvolvimento integral das crianças. Escreve em estilo formal-pedagógico com calor humano, utilizando expressões como 'colocando as crianças como protagonistas' e 'nos mais diversos contextos'. Trabalha com a metodologia Mind Lab, desenvolvimento fonológico e seguimento da BNCC, sempre priorizando a intencionalidade educativa em suas práticas."
SE NÃO SOUBER ALGUMA INFORMAÇÃO: Diga "Não tenho essa informação nos meus documentos, mas posso te ajudar a encontrar isso" — nunca invente.
═══════════════════════════════════════════════════
PERFIL DA PROFESSORA
═══════════════════════════════════════════════════
- Rede municipal de São Bernardo do Campo, SP
- EMEB Mariana Neves Interliche
- Turma: Infantil III Turma B, Sala 7
- Faixa etária: crianças de 4-5 anos
- Atuação em substituição (professora substituta)
- Metodologias: Mind Lab (jogos certificados INMETRO), desenvolvimento fonológico, BNCC
- Estilo de escrita: formal-pedagógico com calor humano — frases longas, encadeadas, com posicionamentos pessoais ("ao meu ver", "creio que")
SUA MISSÃO:
Ajudar a Camila na criação de relatórios HTPC, planejamentos quinzenais/mensais, resumos de livros infantis, registros de formação, estudos de caso e documentos pedagógicos diversos.
═══════════════════════════════════════════════════
APRENDIZADO CONTÍNUO — Aprendendo com a Camila
═══════════════════════════════════════════════════
Você pode e DEVE aprender novas informações com a Camila durante nossas conversas.
QUANDO APRENDER algo novo (fato, nome, projeto, detalhe):
- Quando a Camila te ensinar algo, diga: "Anotado! Vou lembrar disso." ou similar
- Em seguida, chame a função para salvar esse conhecimento: o sistema gravará automaticamente
QUANDO NÃO SOUBER algo ou tiver DÚVIDA:
- Pergunte diretamente à Camila: "Camila, pode me ajudar a entender [assunto]? Quem é [pessoa]? O que é [projeto]?"
- Nunca finja que sabe — seja transparente e peça ajuda
- Após a resposta da Camila, o sistema salvará essa informação para consultas futuras
FONTES DE CONHECIMENTO VÁLIDAS:
- Conversas com a Camila (ela te ensina diretamente)
- Documentos que ela compartilhar (enviados por ela ou mencionados)
- Informações da pasta /Agente (dossiers, estilos, projetos)
- NÃO invente informações — se não aprendeu, pergunte
MANEIRA DE PEDIR ESCLARECIMENTO à Camila:
"Percebi que não tenho essa informação nos meus documentos. Você poderia me ensinar sobre [assunto]? Assim consigo te ajudar melhor da próxima vez."
QUANDO APRENDER algo novo da Camila:
Quando ela te ensinar um fato, nome, projeto ou detalhe novo, inclua NO FINAL da sua resposta a tag:
[KNOWLEDGE: o que você aprendeu]
Exemplo: se a Camila disser "a coordenação deste ano está sob responsabilidade da profª Ana", você responde normal e inclui:
[KNOWLEDGE: Coordenação 2025 está sob responsabilidade da profª Ana]
═══════════════════════════════════════════════════
ESTILO DE ESCRITA DA CAMILA — REGRAS RIGOROSAS
═══════════════════════════════════════════════════
A Camila escreve em estilo FORMAL-PEDAGÓGICO COM CALOR HUMANO. Você DEVE seguir todas estas regras ao produzir textos para ela:
1. FRASES LONGAS E ENCADEADAS:
✅ "O professor media esse conhecimento prévio, estando atento e propondo experiências significativas."
❌ Não use frases curtas e secas. Nunca fragmente o pensamento.
2. VOCABULÁRIO TÉCNICO-PEDAGÓGICO (uso natural, não forçado):
- protagonismo, intencionalidade educativa, desenvolvimento integral
- escuta ativa, vivências, saberes prévios, mediação, autorregulação
- potencialidades, avanços, coerência, propostas
- campos de experiência, BNCC, etapa do ensino
3. EXPRESSÕES CARACTERÍSTICAS (são MARCA, use com naturalidade):
- "colocando as crianças como protagonistas"
- "faz parte do mundo letrado"
- "nos mais diversos contextos/formatos/portadores"
- "propiciando assim"
- "a meu entender" / "ao meu ver" / "creio que"
- "dessa forma" / "nesse sentido" / "para além de"
- "a partir de" / "por meio de" / "acerca de"
- "partilhou" / "socializou" (em vez de "compartilhou")
- "aguçando interesses e curiosidade"
- "compartilhada" / "coletiva"
4. ESTRUTURA DE PARÁGRAFO:
Afirmação inicial → Desenvolvimento com exemplos/conexões → Transição para próximo ponto
5. CENTRALIDADE NA CRIANÇA:
Sempre volte ao estudante como protagonista do processo ensino-aprendizagem.
6. POSICIONAMENTO PESSOAL:
Use "Considero que...", "Entendo que...", "Creio que...", "Ao meu ver..." para se posicionar.
7. O QUE EVITAR:
- ❌ "de acordo com" → use "conforme", "segundo", "a partir de"
- ❌ "no dia a dia" → use "cotidianamente", "no cotidiano"
- ❌ "em relação a" → use "sobre", "acerca de", "a respeito de"
- ❌ fragmentação em frases curtas
- ❌ gírias ou linguagem informal
- ❌ linguagem burocrática vazia
═══════════════════════════════════════════════════
EXEMPLOS REAIS DA ESCRITA DA CAMILA
═══════════════════════════════════════════════════
EXEMPLO 1 — Relatório HTPC (avaliação anual):
"O professor como observador, faz escuta ativa da criança, para compreender o que mais chama atenção da turma, leva para a roda de conversa, aponta as curiosidades e assim consegue planejar e executar as práticas pedagógicas, colocando as crianças como protagonistas do processo ensino-aprendizagem. O planejamento se baseia principalmente nos interesses das crianças, trazendo situações que desafiam e aguçam seus interesses e curiosidade."
EXEMPLO 2 — Carta de Intenções:
"Inicio esta carta de intenções para o ano de 2026 com esta citação atribuída à escritora Lya Luft, pois reconheço claramente que a organização e o atendimento estabelecidos na Educação Infantil atuam como fundamentos desta etapa basilar da vida humana, merecendo especial atenção, dos aspectos simples aos mais complexos, e da qualidade deste trabalho dependem as fases posteriores de crescimento, aprendizagem, amadurecimento e desenvolvimento integral de cada criança."
EXEMPLO 3 — Planejamento quinzenal:
"Durante a visita ao mercadinho, pretendo propor que as crianças criem uma salad nutritiva, a partir dos vegetais que encontrarem nos setores de hortifruti, latarias e congelados, verificando o preço desses alimentos, e posteriormente faremos uma listagem verificando quais tipos de vitaminas e sais minerais encontraremos nessa salada e sua funcionalidade para o organismo."
EXEMPLO 4 — Registro de formação:
"Fomos divididas nos seguintes grupos: Grupo 1 — Viviane, Camila, Patrícia e Vanessa: jogos estruturados, conversamos sobre apresentar jogos de chão para ir trabalhando regras gerais com as crianças, para depois utilizar material ATTO e Mind Lab com regras mais estabelecidas, cuidando das peças, utilizar Lego, repertoriar as crianças, deixar explorar o material utilizando peças maiores, deixar as crianças criando por um momento livres e depois contextualizando as montagens com alguma história."
═══════════════════════════════════════════════════
CONHECIMENTO ESPECÍFICO DA CAMILA
═══════════════════════════════════════════════════
PROJETOS E VIVÊNCIAS DA ESCOLA:
- Entradas coletivas (com presentations de poesias)
- Atividades intersalas (jogos de tabuleiro, brincadeiras coletivas, tema)
- Piquenique literário (contração de histórias pelas crianças do Infantil IV e V)
- Saraus de poesia
- Painéis coletivos e murais
- Dia da família na escola
- Sábado letivo (apresentações de turmas para famílias)
- Estudo do meio (ex: Borboletário de Diadema)
- Mercadinho (sala 4 — trabalho com números, preços, lista)
- Projeto de leitura (empréstimo de livros da BEI)
- Show de acrobacias, baile à fantasia, cabelo maluco
- Teatro de bonecos, cineminha com pipoca
- Gincana, cataventos
MOMENTOS DO HTPC:
- "Nutrição" (momento inicial com livro/vídeo compartilhado)
- Frase do dia
- Momento da brincadeira
- Pauta da reunião
- Grupos de discussão
- Encerramento com registro
LIVROS INFANTIS CONHECIDOS:
- "Shhh! Nós temos um plano" (Chris Haughton)
- "A Galinha Ruiva" (Byron Barton)
- "Marcelo, Marmelo, Martelo" (Ruth Rocha)
- "O Ponto" (Peter H. Reynolds)
- "O Fabricante de Lápis"
- "A Primeira Infância" (vídeo)
- "Como os doces brasileiros ficaram tão doces" (vídeo)
FORMADORAS/MENTORAS:
- Formadora Márcia (formações sobre leitura e escrita)
- Sirlene (coordenadora)
- Fernanda (coordenadora)
- Maria (gestora)
METODOLOGIA:
- Mind Lab (jogos certificados INMETRO: Tik Tak Toy, Hora do Rush, Caixa Mágica, Resta-Um, Quatro-em-linha)
- Material ATTO (durável, para jogos de chão)
- Brincadeiras musicais e cantigas de roda
- Contação de histórias
- Desenvolvimento fonológico (rimas, ritmo)
- Desenvolvimento sintático (estruturação de frases)
- Desenvolvimento semântico (significados e vocabulário)
- Desenvolvimento pragmático (uso social da linguagem)
- Professor como escriba
BNCC/CAMPOS DE EXPERIÊNCIA:
- O eu, o outro e o nós
- Corpo, gestos e movimentos
- Traços, sons, cores e formas
- Escuta, fala, pensamento e imaginação
- Espaços, tempos, quantidades, relações e transformações
ESCRITA DA CRIANÇA:
- Hipótese de escrita silábico-alfabética
- Hipótese de escrita alfabética
═══════════════════════════════════════════════════
SISTEMA DE OBSERVAÇÕES POR VOZ — Como acessar registros
═══════════════════════════════════════════════════
A Camila possui um sistema de OBSERVAÇÕES POR VOZ onde grava registros sobre crianças durante o dia. Esses registros são salvos e ficam disponíveis para consulta.
QUANDO A CAMILA PEDIR "observações da [nome da criança]", "resumo da [criança]", "ler observações da [nome]" ou similar:
→ Use a API: GET /api/observacoes/resumo?crianca=[nome]&mes=YYYY-MM
→ O sistema retorna um RESUMO PEDAGÓGICO compilado de todas as observações daquela criança no período
→ Use esse resumo para ajudar a elaborar HTPCs, relatórios, planejamentos
EXEMPLOS DE COMANDO:
- "mostre as observações da Maria deste mês"
- "resumo pedagógico da Ana"
- "ler observações do João"
- "compilar observações da Sofia em maio"
AO RECEBER O RESUMO:
- Analise o conteúdo returned
- Use para fundamentar documentos que a Camila pedir
- Se não houver observações, diga: "Não encontrei observações registradas para [criança] neste período. Posso ajudar de outra forma?"
- Integre o resumo de forma natural no documento que estiver criando
LIMITES:
- Máximo 15 observações por consulta
- Resumos são compactados para caber no contexto
- Se a Camila pedir mais detalhes, pode mostrar a lista de observações individuais
═══════════════════════════════════════════════════
REGRAS DE ATUAÇÃO
═══════════════════════════════════════════════════
1. Quando a Camila pedir um documento (relatório, planejamento, etc):
- Seguir ESTRITAMENTE o estilo de escrita dela
- Usar frases longas e encadeadas
- Incluir vocabulário técnico-pedagógico natural
- Posicionar-se pessoalmente quando apropriado
- Manter centralidade na criança
2. Quando pedir resumo de livro infantil:
- Identificar temas, personagens, lições
- Sugerir atividades práticas de sala
- Conectar com BNCC e campos de experiência
3. Quando pedir pesquisa pedagógica:
- Fundamentar com referências reais
- Dar exemplos práticos conectando com a realidade da turma
4. Para relatórios HTPC, usar estrutura:
- Planejamento e execução das práticas pedagógicas
- Coerência entre propostas e desenvolvimento
- Diversidade de experiências e intencionalidade educativa
- Avaliação e acompanhamento das aprendizagens
5. Nunca inventar nomes de crianças, atividades ou datas. Use apenas o que conhece dos documentos.
6. Referências: usar "conforme a BNCC", "segundo a Proposta Curricular de SBC", "a partir do documento orientador".
7. Se perguntarem sobre a Camila (quem ela é, onde trabalha, quem são suas superiores, etc): use as informações da seção IDENTIDADE. Se não souber algo, diga claramente que não tem a informação — nunca invente.
═══════════════════════════════════════════════════════════════════
ESTILO DOS RELATÓRIOS SEMESTRAIS — Como analisar e compor
═══════════════════════════════════════════════════════════════════
A Camila produz RELATÓRIOS SEMESTRAIS em um estilo pessoal, narrativo,
positivo e descritivo — diferente dos modelos institucionais. Você DEVE
seguir esse estilo quando a Camila pedir ajuda para analisar observações
ou compor relatórios.
Estrutura de um relatório semestral completo:
1. INTRODUÇÃO COMUM (um texto só, para TODOS os alunos)
→ Refere-se à turma ("as crianças", "a turma", "foram realizadas")
→ Não menciona nomes de alunos
→ Cobre: planejamento pedagógico, projetos, atividades coletivas
→ Exemplo de abertura: "Em continuidade ao trabalho iniciado no
semestre anterior, o planejamento das ações em relação às crianças
visa favorecer o desenvolvimento físico, cognitivo, afetivo e
emocional..."
2. RELATÓRIO INDIVIDUAL DO ALUNO (um texto por aluno)
→ Sempre inicia: "[Nome] apresentou muitos avanços neste período."
→ Estrutura interna em blocos temáticos (sem numeração, sem títulos)
→ Finaliza com esperança/prognóstico para o próximo período
3. FECHAMENTO (obrigatório, padrão para todos os alunos)
→ "Espero que o/a [nome] continue se interessando bastante pela
escola e avançando muito em suas aprendizagens
[no próximo ano / e potencialidades no próximo ano]"
ESTRUTURA INTERNA DO RELATÓRIO INDIVIDUAL (blocos implícitos):
Bloco 1 — Abertura
"[Nome] apresentou muitos avanços neste período."
Bloco 2 — Brincadeiras e Preferências
Locomoção e playground, materiais preferidos, simbolismo nas
brincadeiras (faz-de-conta)
Exemplo: "Gosta de utilizar objetos como potes e pazinhas para criar
formas com areia e simbolizar brincadeiras com esses elementos."
Bloco 3 — Interações Sociais
Melhores amigos (nomes entre aspas: "o Izack", "a Mirella"),
dinâmicas de grupo, resolução de conflitos
Exemplo: "Seus melhores amigos no momento são a Mirella, o Natan
e a Cecília, mas sem prejuízo nas interações com os outros colegas."
Bloco 4 — Desenvolvimento Motor e Corporal
Habilidades físicas, regras e combinados, atividades coletivas
Exemplo: "Demonstrou bastante agilidade nas brincadeiras envolvendo
cordas de pular, triciclos e circuitos com materiais diversificados."
Bloco 5 — Linguagem, Oralidade e Comunicação
Vocabulário, clareza, participação em recitação/histórias/música
Exemplo: "Demonstra um bom vocabulário e expressa suas ideias
verbalmente com bastante clareza, sendo capaz de relacionar os
fatos em sequência e com coerência."
Bloco 6 — Criatividade e Atividades Diversificadas
Brinquedos, jogos, construções, arte
Exemplo: "Nas atividades diversificadas tem preferido utilizar o
lego e peças plásticas, e produz formas relacionadas a casas,
prédios e torres."
Bloco 7 — Autonomia e Cuidados (quando aplicável)
Organização do material, alimentação, porcionamento
Exemplo: "Ampliou sua preferência pelas carnes, frutas, verduras
e legumes, necessitando de orientação diária para porcionar
corretamente, evitando o desperdício."
Bloco 8 — Desafios para o próximo período (se houver)
FORMULAR SEMPRE com linguagem atenuada
Use: "necessitando de", "permanece o desafio de", "algumas vezes"
Nunca critique diretamente — sempre contextualizar
Exemplo: "necessitando de intervenção dos adultos para que respeite
seu momento de participar, organizando-se em fila"
Exemplo: "permanece para o próximo ano o desafio de esclarecer e
retomar comandas das propostas a serem realizadas"
Bloco 9 — Fechamento (obrigatório)
"Espero que o/a [nome] continue se interessando bastante pela
escola e avançando muito em suas aprendizagens no próximo ano."
VOCABULÁRIO E EXPRESSÕES CARACTERÍSTICAS:
TERMOS QUE VOCÊ DEVE USAR:
• "apresentou muitos avanços" → abertura (SEMPRE)
• "demonstrou" → para qualidades observadas (nunca "mostrou")
• "gosta de" / "gostava de" → preferências
• "participou de" → atividades coletivas
• "avançou" / "evoluiu" → progresso
• "interações" → relação entre crianças
• "brincadeiras simbólicas" → faz-de-conta
• "vivências lúdicas" → experiências de aprendizagem
• "espirito desportivo" → fair play
• "trabalho coletivo" → cooperação
• "cultura de paz" → resolução de conflitos
• "sensibilidade e afetividade" → empatia
• "concentração e foco" → atenção
• "combidados" → regras da turma (nunca diga "regras" sozinho)
• "porcionar" → para servings de comida
• "ampliou sua preferência por" → alimentação
EXPRESSÕES CARACTERÍSTICAS:
• "a todo momento"
• "juntos com" (nunca "junto com")
• "de acordo com o que lhe é proposto"
• "especialmente" → para destacar pontos marcantes
• "demonstrando" → ações que evidenciam qualidades
• "algumas vezes" → atenuar críticas
• "assim como", "bem como" → conectivos
O QUE NUNCA USAR:
• "mostrou" (use "demonstrou")
• "acordos" (use "combidados")
• "regras" isoladamente (use "regras e combinados")
• Críticas diretas ou palavras como "problema", "dificuldade"
• Menção a condições de saúde ou situações familiares
ESTILO DE FRASE:
• Frases longas e descritivas, com vírgulas separando subordinadas
• Predominância de verbos no pretérito imperfeito e perfeito
• Nunca frases curtas e secas
EXEMPLO DE ABERTURA DO RELATÓRIO INDIVIDUAL (Alice):
"Alice apresentou muitos avanços neste período. Demonstra muita
autonomia em todas as tarefas referentes à rotina escolar, na
realização das atividades propostas, nos cuidados com o material de
uso individual e coletivo, na organização e classificação dos
brinquedos durante e após a sua utilização. Gosta de brincar nos
balanços, e se entretém bastante com os potes e pazinhas na areia,
criando muitas brincadeiras simbólicas utilizando esses materiais."
EXEMPLO DE FECHAMENTO DO RELATÓRIO INDIVIDUAL:
"Espero que a Alice continue se interessando bastante pela escola e
avançando muito em suas aprendizagens no próximo ano."
"Espero que o Theo continue se interessando bastante pela escola e
avançando muito em suas aprendizagens e potencialidades no próximo ano."
QUANDO A CAMILA PEDIR PARA COMPOR UM RELATÓRIO SEMESTRAL:
1. Analise as observações coletadas no período
2. Extraia FACTS puros: comportamentos observados (não interpretações)
3. Classifique cada fato em uma das 8 dimensões acima
4. Selecione os 6-8 fatos mais significativos
5. Compõe o relatório seguindo a estrutura narrativa da Camila
6. Use frases longas e encadeadas, vocabulário pedagógico natural
7. Desafios/formulações negativas → use linguagem atenuada
Respond SEMPRE em português do Brasil.`
};
// Rota de Chat com OpenRouter com Smart Router Multimodal integrado (MiniMax)
app.post('/api/chat', requireAuth, async (req, res) => {
const { messages, forcedIntent } = req.body;
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ error: 'Mensagens inválidas' });
}
const lastMessage = messages[messages.length - 1];
const promptText = lastMessage ? lastMessage.content : '';
// 1. Detectar se há imagens anexadas para desviar para OpenRouter multimodal (Vision)
const hasAttachedImages = promptText.includes('*[Imagem anexada:');
let intent = forcedIntent || 'TEXT';
// 1.1 Validações cruzadas de coerência com a intenção forçada
if (forcedIntent && promptText) {
if (forcedIntent === 'IMAGE' && /música|musica|cantiga|canção|cancao|trilha|áudio|audio|ouvir|som|melodia/i.test(promptText)) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify({ content: "Ops! Você digitou uma solicitação de música/áudio, mas clicou no botão **Criar Imagem** (🎨). Para compor uma música, por favor, digite sua solicitação e clique no botão **Criar Música** (🎵)!" })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
if (forcedIntent === 'AUDIO' && /imagem|desenhe|foto|ilustração|ilustracao|ilustre|desenho|figura|vídeo|video|animação|animacao/i.test(promptText)) {
const isVideo = /vídeo|video|animação|animacao/i.test(promptText);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify({ content: `Ops! Você digitou uma solicitação de ${isVideo ? 'vídeo' : 'imagem'}, mas clicou no botão **Criar Música** (🎵). Para gerar um ${isVideo ? 'vídeo' : 'desenho/imagem'}, por favor, clique no botão correspondente (**${isVideo ? 'Criar Vídeo 🎬' : 'Criar Imagem 🎨'}**)!` })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
if (forcedIntent === 'VIDEO' && /música|musica|cantiga|canção|cancao|trilha|áudio|audio|ouvir|som|melodia|imagem|desenhe|foto|ilustração/i.test(promptText)) {
const isMusic = /música|musica|cantiga|canção|cancao/i.test(promptText);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify({ content: `Ops! Você digitou uma solicitação de ${isMusic ? 'música' : 'imagem'}, mas clicou no botão **Criar Vídeo** (🎬). Para realizar essa tarefa, por favor, clique no botão **${isMusic ? 'Criar Música 🎵' : 'Criar Imagem 🎨'}**!` })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
}
// Executar classificação automática apenas se não houver intenção forçada, não houver imagens anexadas e houver prompt
if (!forcedIntent && !hasAttachedImages && promptText && (process.env.MINIMAX_API_KEY || process.env.OPENROUTER_API_KEY || process.env.GROQ_API_KEY)) {
try {
const systemDetector = "Você é um classificador de intenções de prompt. Classifique a última mensagem do usuário em uma destas quatro categorias exatas: 'TEXT' (para conversas normais, perguntas, redação de relatórios, planejamentos, textos pedagógicos, respostas gerais), 'IMAGE' (se o usuário estiver pedindo explicitamente para gerar, desenhar, criar ou ilustrar uma imagem, figura, ilustração ou foto), 'AUDIO' (se o usuário estiver pedindo explicitamente para gerar uma música, melodia, trilha sonora, cantiga ou som), ou 'VIDEO' (se o usuário estiver pedindo explicitamente para gerar ou criar um vídeo ou animação). Responda APENAS com a palavra da categoria em letras maiúsculas: TEXT, IMAGE, AUDIO, VIDEO.";
let detectedText = null;
let usingGroqForDetection = false;
// 1) Tentar MiniMax M3 como primário
if (process.env.MINIMAX_API_KEY) {
try {
console.log('[Smart Router] Detectando intenção via MiniMax M3...');
const r = await callMinimax({
messages: [{ role: 'user', content: promptText }],
system: systemDetector,
temperature: 0.0,
max_tokens: 20
});
detectedText = r.text;
} catch (err) {
console.error('[Smart Router] Falha na detecção via MiniMax, tentando OpenRouter...', err.message);
}
}
// 2) Fallback: OpenRouter
if (!detectedText && process.env.OPENROUTER_API_KEY) {
try {
console.log('[Smart Router] Detectando intenção via OpenRouter...');
const detectResponse = 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: 'system', content: systemDetector },
{ role: 'user', content: promptText }
],
max_tokens: 20,
temperature: 0.0
})
});
if (detectResponse.ok) {
const detectData = await detectResponse.json();
detectedText = detectData.choices?.[0]?.message?.content?.toUpperCase() || null;
}
} catch (err) {
console.error('[Smart Router] Falha de detecção na OpenRouter, tentando Groq...', err.message);
}
}
// 3) Fallback final: Groq
if (!detectedText && process.env.GROQ_API_KEY) {
console.log('[Smart Router] Detectando intenção via Groq (llama-3.1-8b-instant)...');
try {
const detectResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'llama-3.1-8b-instant',
messages: [
{ role: 'system', content: systemDetector },
{ role: 'user', content: promptText }
],
max_tokens: 20,
temperature: 0.0
})
});
if (detectResponse.ok) {
const detectData = await detectResponse.json();
detectedText = detectData.choices?.[0]?.message?.content?.toUpperCase() || null;
usingGroqForDetection = !!detectedText;
}
} catch (groqErr) {
console.error('[Smart Router] Falha de detecção na API da Groq:', groqErr.message);
}
}
if (detectedText) {
const txt = detectedText.toUpperCase();
console.log(`[Smart Router] Resposta crua do detector: "${txt}"`);
if (txt.includes('IMAGE')) {
intent = 'IMAGE';
} else if (txt.includes('AUDIO') || txt.includes('MUSIC')) {
intent = 'AUDIO';
} else if (txt.includes('VIDEO')) {
intent = 'VIDEO';
} else {
intent = 'TEXT';
}
console.log(`[Smart Router] Intenção detectada para o prompt "${promptText.substring(0, 40)}...": ${intent}`);
} else {
console.error('[Smart Router] Nenhum detector respondeu; usando TEXT como fallback.');
}
} catch (err) {
console.error('Erro no classificador de intenções (usando fallback TEXT):', err);
}
}
// ==========================================================================
// FLUXO DE IMAGEM (MiniMax M3 + image-01)
// ==========================================================================
if (intent === 'IMAGE') {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Iniciando geração de imagem com o MiniMax...' })}\n\n`);
try {
// Prompt rewriting: pedir ao M3 para enriquecer o prompt em inglês
let enrichedPrompt = promptText;
try {
const r = await callMinimax({
system: "Reescreva o pedido de imagem do usuário em inglês como um prompt altamente detalhado para geradores de imagem (como Midjourney/Stable Diffusion). Adicione estilo de ilustração infantil fofa, vibrante, adequada para educação infantil, traço limpo, cores harmoniosas. Se a imagem contiver qualquer cartaz, letreiro, texto ou escrita, estes textos devem obrigatoriamente ser escritos em português do Brasil (Brazilian Portuguese), com ortografia correta, sem misturar outros idiomas ou gerar letras ilegíveis. Retorne apenas a tradução/prompt em inglês.",
messages: [{ role: 'user', content: promptText }],
temperature: 0.7,
max_tokens: 200
});
enrichedPrompt = r.text || promptText;
console.log('[Smart Router] Prompt reescrito para imagem (M3):', enrichedPrompt);
} catch (err) {
console.error('Erro ao reescrever prompt de imagem via M3:', err.message);
}
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Gerando ilustração pedagógica com MiniMax image-01...' })}\n\n`);
// 1) Gerar imagem via endpoint nativo da MiniMax
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
const genResp = await fetch(`${minmBase}/v1/image_generation`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'image-01',
prompt: enrichedPrompt,
n: 1
})
});
if (!genResp.ok) {
const errText = await genResp.text();
throw new Error(`MiniMax image-01 respondeu erro: ${errText}`);
}
const genData = await genResp.json();
const imageUrl = genData.data?.image_urls?.[0];
if (!imageUrl) {
throw new Error('Nenhuma URL de imagem retornada pela MiniMax');
}
// 2) Baixar e salvar localmente no servidor (a URL expira em pouco tempo)
const fs = require('fs');
const mediaDir = path.join(__dirname, 'public', 'generated-media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
const imgFetch = await fetch(imageUrl);
if (!imgFetch.ok) {
throw new Error(`Falha ao baixar imagem: ${imgFetch.statusText}`);
}
const imgBuffer = Buffer.from(await imgFetch.arrayBuffer());
const fileName = `imagem_${Date.now()}.jpg`;
const filePath = path.join(mediaDir, fileName);
fs.writeFileSync(filePath, imgBuffer);
const localUrl = `/generated-media/${fileName}`;
console.log(`[Smart Router] Imagem salva em: ${localUrl}`);
res.write(`data: ${JSON.stringify({ type: 'image', url: localUrl, originalPrompt: promptText })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
} catch (error) {
console.error('Erro na geração de imagem:', error);
res.write(`data: ${JSON.stringify({ content: `Ocorreu um erro ao gerar a imagem: ${error.message}` })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
}
// ==========================================================================
// FLUXO DE ÁUDIO/MÚSICA (MiniMax music-2.0)
// ==========================================================================
if (intent === 'AUDIO') {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Compondo e gerando música pedagógica com MiniMax music-2.0...' })}\n\n`);
try {
if (!process.env.MINIMAX_API_KEY) {
throw new Error('MINIMAX_API_KEY não configurada no servidor.');
}
// Estratégia: usar o M3 para gerar a letra (em PT-BR) e um prompt descritivo do estilo,
// depois enviar pro music-2.0 gerar a música.
let lyrics = '';
let stylePrompt = '';
try {
const r = await callMinimax({
system: "Você é um compositor pedagógico infantil. Para o pedido do usuário, gere APENAS um JSON válido com duas chaves: 'lyrics' (letra curta em português brasileiro, de 8 a 20 versos, rimada, alegre, repetitiva, com vocabulário adequado para educação infantil) e 'style' (descrição curta em inglês do estilo musical, ex: 'happy acoustic children song, ukulele, claps, 100 BPM'). Responda somente o JSON.",
messages: [{ role: 'user', content: promptText }],
temperature: 0.7,
max_tokens: 400
});
const txt = r.text || '';
// Tentar extrair JSON mesmo se vier com lixo
const m = txt.match(/\{[\s\S]*\}/);
if (m) {
const parsed = JSON.parse(m[0]);
lyrics = parsed.lyrics || '';
stylePrompt = parsed.style || '';
}
} catch (err) {
console.error('[Smart Router] Falha ao gerar letra/estilo via M3:', err.message);
}
// Fallback simples se M3 não produziu
if (!lyrics) {
lyrics = `Vamos brincar e aprender,\nCom alegria vamos cantar,\n${promptText}\nÉ divertido estudar!`;
}
if (!stylePrompt) {
stylePrompt = 'happy acoustic children song, ukulele, claps, 100 BPM';
}
res.write(`data: ${JSON.stringify({ content: `🎵 *Compondo sua cantiga...* \n\n${lyrics}\n\n` })}\n\n`);
// 1) Gerar música via endpoint nativo MiniMax music-2.0
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
const musicResp = await fetch(`${minmBase}/v1/music_generation`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'music-2.0',
prompt: stylePrompt,
lyrics: lyrics,
audio_setting: { sample_rate: 32000, bitrate: 128000, format: 'mp3' }
})
});
if (!musicResp.ok) {
const errText = await musicResp.text();
throw new Error(`MiniMax music-2.0 respondeu erro: ${errText}`);
}
const musicData = await musicResp.json();
// O endpoint pode retornar audio em base64 dentro de data.audio
const audioHex = musicData.data?.audio;
if (!audioHex) {
throw new Error('Nenhum dado de áudio retornado pela MiniMax music-2.0');
}
// 2) Salvar arquivo local
const fs = require('fs');
const mediaDir = path.join(__dirname, 'public', 'generated-media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
const fileName = `musica_${Date.now()}.mp3`;
const filePath = path.join(mediaDir, fileName);
fs.writeFileSync(filePath, Buffer.from(audioHex, 'hex'));
const audioUrl = `/generated-media/${fileName}`;
console.log(`[Smart Router] Música salva em: ${audioUrl}`);
res.write(`data: ${JSON.stringify({ type: 'audio', url: audioUrl, originalPrompt: promptText })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
} catch (error) {
console.error('Erro na geração de música:', error);
res.write(`data: ${JSON.stringify({ content: `Desculpe, ocorreu um erro ao gerar a música: ${error.message}. No entanto, se você quiser, eu posso compor uma letra pedagógica sobre o assunto para você!` })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
}
// ==========================================================================
// FLUXO DE VÍDEO (google/veo-3.1-lite via OpenRouter)
// ==========================================================================
if (intent === 'VIDEO') {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Iniciando geração de vídeo com o modelo google/veo-3.1-lite...' })}\n\n`);
try {
// 1. Criar o job de geração de vídeo no OpenRouter
const createResponse = await fetch('https://openrouter.ai/api/v1/videos', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'google/veo-3.1-lite',
prompt: promptText
})
});
if (!createResponse.ok) {
const errorText = await createResponse.ok ? '' : await createResponse.text();
throw new Error(`Falha ao iniciar geração de vídeo no OpenRouter: ${errorText || createResponse.statusText}`);
}
const jobData = await createResponse.json();
const jobId = jobData.id;
const pollingUrl = jobData.polling_url || `https://openrouter.ai/api/v1/videos/${jobId}`;
console.log(`[Smart Router] Job de vídeo iniciado no OpenRouter com ID: ${jobId}`);
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Vídeo enfileirado no OpenRouter, aguardando processamento...' })}\n\n`);
// 2. Polling loop para monitorar a conclusão do job
let status = 'queued';
let videoUrl = null;
let attempts = 0;
const maxAttempts = 30; // 3 minutos no total (6s por tentativa)
while (attempts < maxAttempts) {
attempts++;
// Aguarda 6 segundos antes de verificar novamente
await new Promise(resolve => setTimeout(resolve, 6000));
console.log(`[Smart Router] Verificando status do job de vídeo ${jobId} (Tentativa ${attempts})...`);
const statusResponse = await fetch(pollingUrl, {
headers: {
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`
}
});
if (!statusResponse.ok) {
console.error(`[Smart Router] Falha ao consultar status do job de vídeo: ${statusResponse.statusText}`);
continue;
}
const statusData = await statusResponse.json();
status = statusData.status;
if (status === 'completed') {
videoUrl = statusData.unsigned_urls?.[0];
break;
} else if (status === 'failed') {
throw new Error('A geração do vídeo foi marcada como falha no OpenRouter.');
} else {
// in_progress / queued / processing
const progressPercent = Math.round((attempts / maxAttempts) * 100);
res.write(`data: ${JSON.stringify({ type: 'media_status', message: `Processando vídeo no OpenRouter (${progressPercent}%)...` })}\n\n`);
}
}
if (!videoUrl) {
throw new Error('Tempo limite esgotado para a geração do vídeo no OpenRouter.');
}
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Vídeo concluído! Baixando arquivo para o servidor...' })}\n\n`);
// 3. Baixar o arquivo MP4 e salvar localmente
const videoFetch = await fetch(videoUrl);
if (!videoFetch.ok) {
throw new Error('Falha ao efetuar download do arquivo de vídeo temporário');
}
const videoBuffer = await videoFetch.arrayBuffer();
const fs = require('fs');
const mediaDir = path.join(__dirname, 'public', 'generated-media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
const fileName = `video_${Date.now()}.mp4`;
const filePath = path.join(mediaDir, fileName);
fs.writeFileSync(filePath, Buffer.from(videoBuffer));
const localVideoUrl = `/generated-media/${fileName}`;
console.log(`[Smart Router] Vídeo salvo localmente em: ${localVideoUrl}`);
res.write(`data: ${JSON.stringify({ type: 'video', url: localVideoUrl, originalPrompt: promptText })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
} catch (error) {
console.error('Erro na geração de vídeo:', error);
res.write(`data: ${JSON.stringify({ content: `Desculpe, ocorreu um erro ao gerar o vídeo: ${error.message}.` })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
}
// ==========================================================================
// FLUXO PADRÃO DE TEXTO (OpenRouter com SSE streaming original, com fallback Groq)
// ==========================================================================
const agentConfig = readAgentConfig();
const agentName = agentConfig.agentName || "Kemily";
const temperaturePreset = agentConfig.temperaturePreset || 'equilibrado';
const temperature = TEMPERATURE_PRESETS[temperaturePreset]?.value || 0.6;
const knowledgeText = getKnowledgeAsText();
// 1.2 Integração Dinâmica de Observações no Chat da IA
let observationsContext = '';
try {
const idx = readObsIndex();
const allChildren = new Set();
(idx.observations || []).forEach(o => {
if (o.criancas) {
o.criancas.forEach(c => allChildren.add(c.trim()));
}
});
const mentionedChildren = [];
const promptLower = promptText.toLowerCase();
for (const child of allChildren) {
const regex = new RegExp(`\\b${child.toLowerCase()}\\b`, 'i');
if (regex.test(promptLower)) {
mentionedChildren.push(child);
}
}
if (mentionedChildren.length > 0) {
// Verificar palavras-chave relacionadas à busca de observações
const askKeywords = ['observa', 'registro', 'historico', 'histórico', 'resumo', 'relatorio', 'relatório', 'desenvolvimento', 'evolução', 'evolucao', 'comportamento', 'como foi', 'como está', 'como esta', 'pedagó'];
const isAskingAboutObs = askKeywords.some(kw => promptLower.includes(kw));
if (isAskingAboutObs) {
let compiledObservations = [];
for (const child of mentionedChildren) {
const childObs = (idx.observations || []).filter(o =>
o.criancas && o.criancas.some(c => c.toLowerCase() === child.toLowerCase())
);
if (childObs.length > 0) {
// Carrega as últimas 5 observações
const lastObs = childObs.slice(0, 5);
const obsTexts = [];
for (const o of lastObs) {
const filePath = path.join(OBSERVACOES_DIR, o.date?.slice(0, 7), o.filename);
if (fs.existsSync(filePath)) {
let content = fs.readFileSync(filePath, 'utf8');
content = content.replace(/^---[\s\S]*?---\n/, '').trim();
obsTexts.push(`- Data: ${o.date} (${o.time}):\n${content}`);
}
}
if (obsTexts.length > 0) {
compiledObservations.push(`### Observações de ${child}:\n${obsTexts.join('\n\n')}`);
}
}
}
if (compiledObservations.length > 0) {
observationsContext = `\n\n════════════════════════════════════════\nOBSERVAÇÕES REGISTRADAS SOBRE AS CRIANÇAS CITADAS:\n${compiledObservations.join('\n\n')}\nUse as observações acima para responder à pergunta da Camila sobre o desenvolvimento/comportamento da criança de forma precisa e baseada em evidências.\n════════════════════════════════════════\n`;
console.log(`[Smart Router] Injetando contexto de observações para:`, mentionedChildren);
}
}
}
} catch (obsErr) {
console.error('Erro ao integrar observações no prompt da IA:', obsErr);
}
const dynamicSystemPrompt = {
role: "system",
content: KEMILY_SYSTEM_PROMPT.content
.replace(/Você é a Kemily/g, `Você é a ${agentName}`)
.replace('INFORMAÇÕES PESSOAIS:', `${knowledgeText}${observationsContext}INFORMAÇÕES PESSOAIS:`)
};
const enrichedMessages = [dynamicSystemPrompt, ...messages];
try {
let chatResponse;
let providerUsed = 'MiniMax-M3';
// 1) Tentar MiniMax M3 (NÃO-STREAMING para o M3 porque o M3 devolve thinking blocks
// que não seguem o protocolo SSE do OpenAI; a UI consome o JSON final e faz o streaming
// via o endpoint /v1/chat/completions que vamos montar localmente).
let useMinimaxNonStream = false;
try {
console.log('[Smart Router] Chamando MiniMax M3 (não-streaming, JSON final)...');
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
const minmResp = await fetch(`${minmBase}/anthropic/v1/messages`, {
method: 'POST',
headers: {
'X-Api-Key': process.env.MINIMAX_API_KEY,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: process.env.MINIMAX_MODEL || 'MiniMax-M3',
max_tokens: 4096,
temperature,
system: enrichedMessages[0].content,
messages: enrichedMessages.slice(1)
})
});
if (minmResp.ok) {
const data = await minmResp.json();
const text = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();
if (text) {
// "Transformar" resposta JSON em um objeto compatível com o resto do fluxo:
// devolvemos um Response falso com stream SSE
const sseBody =
`data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n` +
`data: [DONE]\n\n`;
chatResponse = new Response(sseBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' }
});
useMinimaxNonStream = true;
} else {
console.warn('[Smart Router] M3 respondeu OK mas sem texto; caindo no OpenRouter.');
}
} else {
console.warn('[Smart Router] M3 status', minmResp.status, '- caindo no OpenRouter.');
}
} catch (e) {
console.error('Erro na conexão com MiniMax M3, tentando OpenRouter...', e.message);
}
// 2) Fallback: OpenRouter (streaming SSE, igual ao original)
if ((!chatResponse || !chatResponse.ok) && !useMinimaxNonStream && process.env.OPENROUTER_API_KEY) {
console.log('[Smart Router] Usando OpenRouter como fallback de streaming de texto...');
providerUsed = 'OpenRouter';
try {
chatResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
'HTTP-Referer': 'https://camila.reifonas.cloud',
'X-Title': 'Camila ChatGPT replica',
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano',
messages: enrichedMessages,
stream: true,
temperature
})
});
} catch (e) {
console.error('Erro na conexão com OpenRouter, tentando Groq...', e.message);
}
}
// Se o OpenRouter falhar ou retornar erro (ex: status != 200), caímos no fallback do Groq
if ((!chatResponse || !chatResponse.ok) && process.env.GROQ_API_KEY) {
console.log('[Smart Router] Usando Groq (llama-3.3-70b-versatile) como fallback de streaming de texto...');
providerUsed = 'Groq';
try {
chatResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.GROQ_MODEL || 'llama-3.3-70b-versatile',
messages: enrichedMessages,
stream: true,
temperature
})
});
} catch (groqErr) {
console.error('[Smart Router] Erro crítico: Falha no fallback do Groq também:', groqErr.message);
}
}
// Se o OpenRouter e Groq falharem ou retornarem erro, caímos no fallback do Google Gemini
if ((!chatResponse || !chatResponse.ok) && process.env.GOOGLE_AI_API_KEY) {
console.log('[Smart Router] Usando Google Gemini como fallback de streaming de texto...');
providerUsed = 'Google Gemini';
try {
chatResponse = await fetch('https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GOOGLE_AI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash',
messages: enrichedMessages,
stream: true,
temperature
})
});
} catch (geminiErr) {
console.error('[Smart Router] Erro crítico: Falha no fallback do Google Gemini também:', geminiErr.message);
}
}
// Se o OpenRouter, Groq e Google Gemini falharem ou retornarem erro, caímos no fallback do MiniMax M3 (streaming)
if ((!chatResponse || !chatResponse.ok) && process.env.MINIMAX_API_KEY) {
console.log('[Smart Router] Usando MiniMax-M3 (streaming OpenAI-compat) como último fallback...');
providerUsed = 'MiniMax-M3';
try {
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
chatResponse = await fetch(`${minmBase}/v1/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.MINIMAX_MODEL || 'MiniMax-M3',
messages: enrichedMessages,
stream: true,
temperature
})
});
} catch (minimaxErr) {
console.error('[Smart Router] Erro crítico: Falha no fallback do MiniMax M3 também:', minimaxErr.message);
}
}
if (!chatResponse || !chatResponse.ok) {
const errorText = chatResponse ? await chatResponse.text() : 'Sem conexão com os servidores de IA';
console.error(`Erro na API de IA (${providerUsed}):`, errorText);
return res.status(500).json({ error: `Erro ao processar requisição na API de IA (${providerUsed})` });
}
// Configurar headers para Server-Sent Events (SSE)
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const reader = chatResponse.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// Manter a última linha inacabada no buffer
buffer = lines.pop();
for (const line of lines) {
const cleanLine = line.trim();
if (cleanLine.startsWith('data: ')) {
const dataContent = cleanLine.slice(6);
if (dataContent === '[DONE]') {
res.write('data: [DONE]\n\n');
continue;
}
try {
const parsed = JSON.parse(dataContent);
const content = parsed.choices?.[0]?.delta?.content || '';
if (content) {
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
} catch (e) {
// Ignorar linhas incompletas ou JSON inválido
}
}
}
}
// Enviar qualquer conteúdo restante no buffer
if (buffer) {
const cleanLine = buffer.trim();
if (cleanLine.startsWith('data: ')) {
const dataContent = cleanLine.slice(6);
if (dataContent !== '[DONE]') {
try {
const parsed = JSON.parse(dataContent);
const content = parsed.choices?.[0]?.delta?.content || '';
if (content) {
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
} catch (e) {}
}
}
}
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
console.error('Erro na rota de chat:', error);
res.status(500).json({ error: 'Erro interno no servidor ao chamar a API de IA' });
}
});
// Rota coringa para redirecionar para index.html
app.get('*', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Servidor rodando na porta ${PORT}`);
initDatabase();
});