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' }); } }); // 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 { // 0. 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, sem textos/letras na imagem. 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(); });