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 { YoutubeTranscript } = require('youtube-transcript'); 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; // Tentativa 1: MiniMax M3 com timeout de 45s try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 45000); 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 }), signal: controller.signal }); clearTimeout(timeoutId); if (res.ok) { const data = await res.json(); const text = (data.content || []) .filter(b => b.type === 'text') .map(b => b.text) .join('') .trim(); if (text) return { text, raw: data, provider: 'minimax' }; } else { const txt = await res.text(); console.warn(`[AI Text] MiniMax respondeu ${res.status}: ${txt}. Acionando fallback...`); } } catch (err) { console.warn('[AI Text] Falha/Timeout no MiniMax (' + err.message + '). Acionando fallback...'); } // Fallback 1: Groq (Llama 3.3 70b / Llama 3.1 8b) if (process.env.GROQ_API_KEY) { try { console.log('[AI Text Fallback] Acionando Groq...'); const groqMessages = system ? [{ role: 'system', content: system }, ...messages] : messages; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); const res = 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.3-70b-versatile', messages: groqMessages, temperature, max_tokens }), signal: controller.signal }); clearTimeout(timeoutId); if (res.ok) { const data = await res.json(); const text = data.choices?.[0]?.message?.content?.trim(); if (text) return { text, raw: data, provider: 'groq' }; } } catch (groqErr) { console.warn('[AI Text Fallback] Falha no Groq:', groqErr.message); } } // Fallback 2: Google AI Gemini if (process.env.GOOGLE_AI_API_KEY) { try { console.log('[AI Text Fallback] Acionando Google AI Gemini...'); const geminiModel = process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'; const promptText = (system ? `System: ${system}\n\n` : '') + messages.map(m => `${m.role}: ${m.content}`).join('\n'); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: promptText }] }], generationConfig: { temperature, maxOutputTokens: max_tokens } }), signal: controller.signal }); clearTimeout(timeoutId); if (res.ok) { const data = await res.json(); const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim(); if (text) return { text, raw: data, provider: 'gemini' }; } } catch (gemErr) { console.warn('[AI Text Fallback] Falha no Gemini:', gemErr.message); } } throw new Error('Todas as IAs de geração de texto estão indisponíveis no momento. Por favor, tente novamente.'); } app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ limit: '50mb', extended: true })); app.use(cookieParser(process.env.SESSION_SECRET || 'pedagog-secret')); // Middleware para verificar autenticação const requireAuth = (req, res, next) => { const session = req.signedCookies.pedagog_session || req.cookies.pedagog_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'); } } }; // Handlers globais para evitar crash do processo Node.js por falhas transitórias de conexão process.on('uncaughtException', (err) => { console.error('[CRITICAL] Exceção não capturada no processo (evitado crash):', err); }); process.on('unhandledRejection', (reason, promise) => { console.error('[CRITICAL] Rejeição de Promise não tratada (evitado crash):', reason); }); const fs = require('fs'); const { Pool } = require('pg'); const dbPool = new Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 10000, idleTimeoutMillis: 30000, max: 25 }); dbPool.on('error', (err) => { console.error('[Database Pool Error] Erro inesperado em cliente de banco (não fatal):', err.message); }); async function backupMediaFile(filePath, buffer) { try { const filename = path.basename(filePath); let contentType = 'application/octet-stream'; const ext = path.extname(filename).toLowerCase(); if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg'; else if (ext === '.png') contentType = 'image/png'; else if (ext === '.gif') contentType = 'image/gif'; else if (ext === '.mp3') contentType = 'audio/mpeg'; else if (ext === '.mp4') contentType = 'video/mp4'; else if (ext === '.wav') contentType = 'audio/wav'; // Timeout para não travar em caso de lentidão temporária do banco const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout no backup de mídia')), 5000)); const queryPromise = dbPool.query( `INSERT INTO escola.arquivos (filename, content_type, dados) VALUES ($1, $2, $3) ON CONFLICT (filename) DO UPDATE SET dados = $3, content_type = $2`, [filename, contentType, buffer] ); await Promise.race([queryPromise, timeoutPromise]); console.log(`[Media Backup] Backup do arquivo ${filename} salvo no banco escola.arquivos.`); } catch (err) { console.warn(`[Media Backup] Aviso ao fazer backup do arquivo ${filePath} no banco:`, err.message); } } 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: "PedagogIA", iaAvatarUrl: "assets/pedagog_ia.png", userAvatarUrl: "assets/pedagog_user.png", temperaturePreset: "equilibrado" }; function readAgentConfig() { return agentConfigCache; } async function writeAgentConfig(config) { agentConfigCache = { agentName: config.agentName || "PedagogIA", iaAvatarUrl: config.iaAvatarUrl || config.kemilyAvatarUrl || "assets/pedagog_ia.png", userAvatarUrl: config.userAvatarUrl || config.camilaAvatarUrl || "assets/pedagog_user.png", temperaturePreset: config.temperaturePreset || 'equilibrado' }; try { await dbPool.query( `INSERT INTO escola.config (key, agent_name, ia_avatar_url, user_avatar_url, temperature_preset, updated_at) VALUES ('current', $1, $2, $3, $4, NOW()) ON CONFLICT (key) DO UPDATE SET agent_name = EXCLUDED.agent_name, ia_avatar_url = EXCLUDED.ia_avatar_url, user_avatar_url = EXCLUDED.user_avatar_url, temperature_preset = EXCLUDED.temperature_preset, updated_at = NOW();`, [agentConfigCache.agentName, agentConfigCache.iaAvatarUrl, agentConfigCache.userAvatarUrl, agentConfigCache.temperaturePreset] ); fs.writeFileSync(CONFIG_FILE_PATH, JSON.stringify(agentConfigCache, null, 2), 'utf8'); const botConfigDir = path.join(__dirname, '..', 'BotVPS', 'data'); if (fs.existsSync(botConfigDir)) { fs.writeFileSync(path.join(botConfigDir, 'agent_config.json'), JSON.stringify(agentConfigCache, 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 = 'young female vocals, singing in clear native brazilian portuguese accent, no european accent, sweet and warm voice'; if (voz === 'homem') { voiceDesc = 'male vocals, singing in clear native brazilian portuguese accent, warm and gentle singing voice'; } else if (voz === 'crianca') { voiceDesc = 'real young kid vocals, little child singing, boy or girl child voice, playful children voice, native brazilian portuguese kid accent, definitely no adult voices'; } 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, singing exclusively in native Brazilian Portuguese (PT-BR) accent, authentic Brazilian vocals, clear pronunciation`; // 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); const audioBuffer = Buffer.from(audioHex, 'hex'); fs.writeFileSync(filePath, audioBuffer); await backupMediaFile(filePath, audioBuffer); const audioUrl = `/generated-media/${fileName}`; // 6. Retornar dados para o frontend const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; const dbRes = await dbPool.query( `INSERT INTO escola.estudio_musicas (usuario_id, voz, ritmo, tema, letra, audio_url) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, [usuarioId, voz, ritmo, tema, lyrics, audioUrl] ); res.json({ success: true, id: dbRes.rows[0].id, lyrics: lyrics, audioUrl: audioUrl }); } catch (error) { console.error('Erro no Estúdio Musical:', error); res.status(500).json({ error: error.message || 'Ocorreu um erro ao gerar a música' }); } }); // ============================================================ // ROTAS DE HISTÓRICO DO ESTÚDIO MUSICAL // ============================================================ app.get('/api/music/list', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query(`SELECT id, voz, ritmo, tema, created_at FROM escola.estudio_musicas WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId]); res.json(dbRes.rows); } catch (err) { res.status(500).json({ error: 'Erro ao listar histórico.' }); } }); app.get('/api/music/:id', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query(`SELECT * FROM escola.estudio_musicas WHERE id = $1 AND usuario_id = $2`, [req.params.id, usuarioId]); if (dbRes.rows.length === 0) return res.status(404).json({ error: 'Não encontrada.' }); res.json(dbRes.rows[0]); } catch (err) { res.status(500).json({ error: 'Erro ao buscar.' }); } }); app.delete('/api/music/:id', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { // Apagar também o arquivo físico se quiser const fileRes = await dbPool.query(`SELECT audio_url FROM escola.estudio_musicas WHERE id = $1 AND usuario_id = $2`, [req.params.id, usuarioId]); if (fileRes.rows.length > 0 && fileRes.rows[0].audio_url) { const fs = require('fs'); const path = require('path'); const filePath = path.join(__dirname, 'public', fileRes.rows[0].audio_url); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } await dbPool.query(`DELETE FROM escola.estudio_musicas WHERE id = $1 AND usuario_id = $2`, [req.params.id, usuarioId]); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Erro ao deletar.' }); } }); // ============================================================ // ROTAS DO ESTÚDIO: FÁBRICA DE QUADRINHOS // ============================================================ // 1. Gerar o roteiro (script) dos quadrinhos usando MiniMax-M3 app.post('/api/comics/generate-script', requireAuth, async (req, res) => { const { tema, quantidade, baloes, proporcao, personagens, cenario, existingPanels, character_description, continuationType } = req.body; if (!tema) { return res.status(400).json({ error: 'O tema dos quadrinhos é obrigatório.' }); } try { let prompt = ''; if (existingPanels && Array.isArray(existingPanels) && existingPanels.length > 0) { const startPanelNum = continuationType === 'extend' ? existingPanels.length + 1 : 1; prompt = `Você está continuando ou se baseando em um projeto de história em quadrinhos existente. DADOS DO PROJETO EXISTENTE: - Descrição visual física e de estilo constante dos personagens (em inglês): "${character_description || ''}" - Painéis já existentes (roteiro anterior): ${existingPanels.map(p => ` * Quadrinho ${p.panel_number || p.panelNumber}: [Visual] ${p.image_prompt || p.imagePrompt || 'N/A'} | [Fala/Legenda] ${p.dialogue}`).join('\n')} INSTRUÇÕES DE GERAÇÃO: Tipo de continuidade: ${continuationType === 'extend' ? 'Estender a mesma história (continuação cronológica direta, criando novos painéis sequenciais)' : 'Criar uma nova história separada (spin-off com os mesmos personagens, recomeçando do painel 1)'}. Tema/Objetivo da nova fase: "${tema}". Gere exatamente ${quantidade} novos quadrinhos. Baloes de falas: ${baloes ? 'Sim, cada quadrinho deve conter um diálogo/fala extremamente conciso e curto, contendo no máximo 10 palavras no total.' : 'Não, a história é silenciosa, sem falas, apenas a descrição da cena'}. Proporção visual: ${proporcao}. Cenário/Local principal da nova fase: ${cenario}. Gere um JSON perfeitamente válido contendo: { "title": "${continuationType === 'extend' ? 'Manter o título original ou propor um subtítulo condizente' : 'Novo Título da História'}", "character_description": "${character_description || 'Descrição visual constante dos personagens em inglês'}", "panels": [ { "panel_number": ${startPanelNum}, "image_prompt": "Prompt visual detalhado em inglês para o gerador de imagens da MiniMax descrevendo a cena deste quadrinho. IMPORTANTE PARA CONSISTÊNCIA: Substitua TODOS os nomes de personagens por sua descrição física minuciosa em inglês (obtida de character_description) em cada quadrinho que aparecerem (ex: em vez de apenas escrever 'Lucas', escreva a descrição detalhada dele com roupas e cabelos específicos, etc). O estilo deve ser de ilustração de livro infantil de alta qualidade, 3D Pixar, traço limpo.", "dialogue": "Falas do quadrinho em português (ex: 'Lucas: Veja aquilo!') com NO MÁXIMO 10 palavras, ou narração curta caso não tenha balões." }, ... ] } Responda APENAS o JSON válido, sem qualquer texto explicativo antes ou depois.`; } else { prompt = `Crie uma história em quadrinhos pedagógica infantil com o tema: "${tema}". A história deve conter exatamente ${quantidade} quadrinhos. Baloes de falas: ${baloes ? 'Sim, cada quadrinho deve conter um diálogo/fala extremamente conciso e curto, contendo no máximo 10 palavras no total.' : 'Não, a história é silenciosa, sem falas, apenas a descrição da cena'}. Proporção visual: ${proporcao}. Personagens participantes: ${JSON.stringify(personagens)}. Cenário/Local principal: ${cenario}. Gere um JSON perfeitamente válido contendo: { "title": "Título da História", "character_description": "Descrição visual constante dos personagens em inglês (para usar em geradores de imagens, detalhando roupas específicas, cores, características físicas de forma fixa para manter a consistência).", "panels": [ { "panel_number": 1, "image_prompt": "Prompt visual detalhado em inglês para o gerador de imagens da MiniMax descrevendo a cena deste quadrinho. IMPORTANTE PARA CONSISTÊNCIA: Substitua TODOS os nomes de personagens por sua descrição física minuciosa em inglês (do character_description) em cada quadrinho que aparecerem (ex: não escreva apenas 'Mariana', escreva a descrição dela detalhando cabelo e roupas de forma fixa e idêntica em todos os quadrinhos). O estilo deve ser de ilustração de livro infantil de alta qualidade, 3D Pixar, traço limpo.", "dialogue": "Falas do quadrinho em português (ex: 'Lucas: Veja aquilo!') com NO MÁXIMO 10 palavras, ou narração curta caso não tenha balões." }, ... ] } Responda APENAS o JSON válido, sem qualquer texto explicativo antes ou depois.`; } const r = await callMinimax({ system: "Você é um roteirista pedagógico especialista em histórias em quadrinhos infantis para educação básica. Você responde apenas com estruturas JSON válidas.", messages: [{ role: 'user', content: prompt }], temperature: 0.3, max_tokens: 3000 }); let jsonText = r.text; let scriptData; try { const firstBrace = jsonText.indexOf('{'); const lastBrace = jsonText.lastIndexOf('}'); if (firstBrace !== -1 && lastBrace !== -1) { const jsonString = jsonText.substring(firstBrace, lastBrace + 1); scriptData = JSON.parse(jsonString); } else { jsonText = jsonText.replace(/```json/gi, '').replace(/```/g, '').trim(); scriptData = JSON.parse(jsonText); } } catch (parseErr) { console.error('Falha ao parsear JSON do roteiro de quadrinhos. Resposta original:', jsonText); throw new Error('Formato de resposta inválido retornado pela IA: ' + parseErr.message); } res.json(scriptData); } catch (error) { console.error('Erro ao gerar roteiro dos quadrinhos:', error); res.status(500).json({ error: 'Erro ao compor a história em quadrinhos: ' + error.message }); } }); // 2. Gerar uma imagem individual para um quadrinho usando MiniMax image-01 com Fallback Automático app.post('/api/comics/generate-frame', requireAuth, async (req, res) => { const { prompt, proporcao } = req.body; if (!prompt) { return res.status(400).json({ error: 'O prompt de imagem é obrigatório.' }); } 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 = `quadrinho_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`; const filePath = path.join(mediaDir, fileName); let imageBuffer = null; let providerUsed = 'minimax'; // Tentativa 1: MiniMax image-01 com timeout de 38s try { const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, ''); const apiKey = process.env.MINIMAX_API_KEY; if (apiKey) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 38000); const genResp = await fetch(`${minmBase}/v1/image_generation`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'image-01', prompt: prompt, n: 1 }), signal: controller.signal }); clearTimeout(timeoutId); if (genResp.ok) { const genData = await genResp.json(); const imageUrl = genData.data?.image_urls?.[0]; if (imageUrl) { const imgFetch = await fetch(imageUrl); if (imgFetch.ok) { imageBuffer = Buffer.from(await imgFetch.arrayBuffer()); } } } else { const errText = await genResp.text(); console.warn('[Comics Image] MiniMax retornou erro, acionando fallback inteligente:', errText); } } } catch (minimaxErr) { console.warn('[Comics Image] Falha/Timeout no MiniMax image-01 (' + minimaxErr.message + '), acionando gerador fallback...'); } // Tentativa 2 (Fallback Resiliente Multi-Engine com Retries): if (!imageBuffer) { const is169 = proporcao === '16:9' || (prompt && prompt.includes('16:9')); const width = is169 ? 1280 : 1024; const height = is169 ? 720 : 768; const cleanPrompt = (prompt || 'cute children book illustration').replace(/[^\w\s,.-]/gi, ''); const enhancedPrompt = encodeURIComponent(`${cleanPrompt}, 3d pixar disney style, digital children book illustration, vibrant colors, masterpiece, cinematic lighting`); const engines = ['flux', 'turbo']; for (const engine of engines) { if (imageBuffer) break; for (let attempt = 1; attempt <= 2; attempt++) { try { providerUsed = `pollinations-${engine}`; const seed = Math.floor(Math.random() * 1000000); const fallbackUrl = `https://image.pollinations.ai/prompt/${enhancedPrompt}?width=${width}&height=${height}&model=${engine}&nologo=true&seed=${seed}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 25000); const fbResp = await fetch(fallbackUrl, { signal: controller.signal }); clearTimeout(timeoutId); if (fbResp.ok) { imageBuffer = Buffer.from(await fbResp.arrayBuffer()); console.log(`[Comics Image] Imagem gerada com sucesso via Fallback (${providerUsed}, tentativa ${attempt}).`); break; } else { console.warn(`[Comics Image] Fallback ${providerUsed} tentativa ${attempt} respondeu ${fbResp.status}`); if (fbResp.status === 429) { await new Promise(r => setTimeout(r, 700 * attempt)); } } } catch (fbErr) { console.warn(`[Comics Image] Erro no fallback ${engine} tentativa ${attempt}:`, fbErr.message); } } } } if (!imageBuffer) { return res.status(500).json({ error: 'Não foi possível gerar a ilustração do quadro no momento. Por favor, tente novamente.' }); } try { fs.writeFileSync(filePath, imageBuffer); // Backup assíncrono não bloqueante backupMediaFile(filePath, imageBuffer).catch(err => { console.warn('[Comics Backup] Falha em background no backup da imagem:', err.message); }); res.json({ imageUrl: `/generated-media/${fileName}`, provider: providerUsed }); } catch (saveErr) { console.error('Erro ao salvar imagem localmente:', saveErr); res.status(500).json({ error: 'Erro ao persistir imagem gerada: ' + saveErr.message }); } }); // 3. Compilar a apresentação de vídeo usando FFmpeg a partir dos painéis de quadrinhos (Com Narração IA) app.post('/api/comics/generate-video', requireAuth, async (req, res) => { const { frames, musicSelection, frameDuration, narrationVoice, titulo } = req.body; if (!frames || !Array.isArray(frames) || frames.length === 0) { return res.status(400).json({ error: 'Nenhum painel enviado para geração de vídeo.' }); } const baseDuration = frameDuration ? parseInt(frameDuration) : 5; const musicFile = musicSelection || 'alegre'; const voice = narrationVoice || 'none'; // 'none', 'mulher', 'homem' try { const fs = require('fs'); const path = require('path'); const { exec } = require('child_process'); const { MsEdgeTTS, OUTPUT_FORMAT } = require('msedge-tts'); const mediaDir = path.join(__dirname, 'public', 'generated-media'); if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } const tempFileId = `${Date.now()}_${Math.floor(Math.random() * 1000)}`; // 1. Mapear trilha musical let bgMusicPath = null; if (musicFile !== 'sem_musica') { bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'alegre.mp3'); if (musicFile === 'calma') { bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'calma.mp3'); } else if (musicFile === 'aventura') { bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'aventura.mp3'); } if (!fs.existsSync(bgMusicPath)) bgMusicPath = null; } // Estrutura para armazenar tempos e caminhos dos áudios de narração const panelDurations = []; const narrationAudioFiles = []; // 2. Se a narração estiver ativada (mulher ou homem) if (voice === 'mulher' || voice === 'homem') { const voiceName = voice === 'homem' ? 'pt-BR-AntonioNeural' : 'pt-BR-FranciscaNeural'; console.log(`[Video Comics] Gerando narração por IA (${voiceName})...`); // Gerar textos narrativos adaptados por IA para acompanhar a duração e fechar no final let narrations = []; try { const narrPrompt = `Você é um narrador especialista em audiolivros infantis e histórias em quadrinhos pedagógicas. Abaixo estão as cenas e falas dos quadros de uma História em Quadrinhos: Título: "${titulo || 'História em Quadrinhos'}" ${frames.map((f, idx) => `Quadro ${idx + 1}: Fala/Diálogo: "${f.dialogue || ''}" | Descrição visual: "${f.image_prompt || ''}"`).join('\n')} Por favor, crie um texto de narração falado, acolhedor e envolvente em português do Brasil (pt-BR) para CADA QUADRO. - O texto de cada quadro deve ser fluido e natural para ser lido em voz alta. - Expanda as falas para contar a história com ritmo infantil acolhedor. - O ÚLTIMO QUADRO deve conter uma frase final marcante e carinhosa de conclusão da história. - Retorne estritamente um JSON no seguinte formato: { "narrations": [ { "panel_index": 0, "text": "Texto da narração do quadro 1..." } ] }`; const aiResp = await callMinimax({ messages: [{ role: 'user', content: narrPrompt }], temperature: 0.7, max_tokens: 1500 }); const jsonMatch = aiResp.text.match(/\{[\s\S]*\}/); if (jsonMatch) { const parsed = JSON.parse(jsonMatch[0]); narrations = parsed.narrations || []; } } catch (aiErr) { console.warn('[Video Comics] Erro ao gerar roteiro por IA, usando diálogos originais:', aiErr.message); } // Sintetizar o áudio TTS de cada quadro const tts = new MsEdgeTTS(); await tts.setMetadata(voiceName, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3); for (let i = 0; i < frames.length; i++) { const item = narrations.find(n => n.panel_index === i); let narrText = item?.text || frames[i].dialogue || frames[i].image_prompt || `Quadro ${i + 1}`; // Limpar prefixos de falas narrText = narrText.replace(/^(Maria|João|Lucas|Pedro|Ana|Professora):\s*/i, '').trim(); const audioPath = path.join(mediaDir, `narr_${tempFileId}_p${i}.mp3`); try { await tts.toFile(audioPath, narrText); narrationAudioFiles.push(audioPath); // Calcular duração estimada do MP3 (~6000 bytes por segundo a 48kbps mono MP3) const stats = fs.statSync(audioPath); const audioSecs = Math.max(3, Math.ceil(stats.size / 6000)); // O tempo do quadro é o máximo entre a duração base e o tempo da narração + 1s de respiro const frameTime = Math.max(baseDuration, audioSecs + 1); panelDurations.push(frameTime); } catch (ttsErr) { console.warn(`[Video Comics] Falha TTS quadro ${i}:`, ttsErr.message); panelDurations.push(baseDuration); } } } else { // Sem narração de voz frames.forEach(() => panelDurations.push(baseDuration)); } // 3. Montar o arquivo concat de imagens do FFmpeg const concatFilePath = path.join(__dirname, `concat_${tempFileId}.txt`); let concatContent = ''; frames.forEach((frame, idx) => { const localFilePath = path.join(__dirname, 'public', frame.imageUrl); concatContent += `file '${localFilePath}'\nduration ${panelDurations[idx]}\n`; }); const lastLocalFilePath = path.join(__dirname, 'public', frames[frames.length - 1].imageUrl); concatContent += `file '${lastLocalFilePath}'\n`; fs.writeFileSync(concatFilePath, concatContent); // 4. Se houver narração de voz, concatenar os áudios individuais da narração let concatAudioPath = null; if (narrationAudioFiles.length > 0) { const concatAudioTxtPath = path.join(__dirname, `concat_audio_${tempFileId}.txt`); let audioConcatStr = ''; narrationAudioFiles.forEach(aPath => { audioConcatStr += `file '${aPath}'\n`; }); fs.writeFileSync(concatAudioTxtPath, audioConcatStr); concatAudioPath = path.join(mediaDir, `narr_full_${tempFileId}.mp3`); await new Promise((resolve) => { exec(`ffmpeg -y -f concat -safe 0 -i "${concatAudioTxtPath}" -c copy "${concatAudioPath}"`, () => { try { fs.unlinkSync(concatAudioTxtPath); } catch (e) {} resolve(); }); }); } // 5. Construir o comando do FFmpeg final const outputFileName = `video_quadrinhos_${tempFileId}.mp4`; const outputFilePath = path.join(mediaDir, outputFileName); const totalDuration = panelDurations.reduce((a, b) => a + b, 0); let ffmpegCommand = ''; if (concatAudioPath && bgMusicPath) { // Narração + Música de fundo (mixagem com volume suave na música) ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${concatAudioPath}" -i "${bgMusicPath}" -filter_complex "[1:a]volume=1.2[narr];[2:a]volume=0.15[bg];[narr][bg]amix=inputs=2:duration=first[aout]" -map 0:v -map "[aout]" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest -t ${totalDuration} "${outputFilePath}"`; } else if (concatAudioPath) { // Narração apenas (sem música de fundo) ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${concatAudioPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest -t ${totalDuration} "${outputFilePath}"`; } else if (bgMusicPath) { // Apenas música de fundo (sem voz) ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${bgMusicPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest -t ${totalDuration} "${outputFilePath}"`; } else { // Sem áudio ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -c:v libx264 -pix_fmt yuv420p -t ${totalDuration} "${outputFilePath}"`; } console.log('[FFmpeg] Executando comando de vídeo:', ffmpegCommand); exec(ffmpegCommand, (error, stdout, stderr) => { // Limpeza de arquivos temporários try { if (fs.existsSync(concatFilePath)) fs.unlinkSync(concatFilePath); if (concatAudioPath && fs.existsSync(concatAudioPath)) fs.unlinkSync(concatAudioPath); narrationAudioFiles.forEach(f => { if (fs.existsSync(f)) fs.unlinkSync(f); }); } catch (cleanErr) { console.warn('Erro ao limpar arquivos temporários do vídeo:', cleanErr.message); } if (error) { console.error('Erro ao processar FFmpeg:', error, stderr); return res.status(500).json({ error: 'Erro ao gerar o vídeo da apresentação.' }); } // Backup do vídeo gerado no banco em background (async () => { try { const videoBuffer = fs.readFileSync(outputFilePath); await backupMediaFile(outputFilePath, videoBuffer); } catch (backupErr) { console.error('[Media Backup] Erro ao fazer backup do vídeo do FFmpeg:', backupErr); } })(); res.json({ videoUrl: `/generated-media/${outputFileName}` }); }); } catch (error) { console.error('Erro ao compilar vídeo de apresentação:', error); res.status(500).json({ error: 'Erro ao gerar o vídeo: ' + error.message }); } }); // --- ROTAS DE PROJETOS DE HQ (SALVAR, LISTAR, CARREGAR, EXCLUIR) --- // 1. Listar projetos app.get('/api/comics/projects', requireAuth, async (req, res) => { const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000'; try { const result = await dbPool.query( 'SELECT id, titulo, tema, cenario, proporcao, character_description_global, character_description_english, created_at, updated_at FROM escola.projetos_comics WHERE usuario_id = $1 ORDER BY updated_at DESC;', [usuarioId] ); res.json(result.rows || []); } catch (error) { console.error('Erro ao listar projetos de quadrinhos:', error.message); res.json([]); } }); // 2. Carregar projeto específico app.get('/api/comics/projects/:id', requireAuth, async (req, res) => { const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000'; const { id } = req.params; try { const projectRes = await dbPool.query( 'SELECT * FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2;', [id, usuarioId] ); if (projectRes.rows.length === 0) { return res.status(404).json({ error: 'Projeto não encontrado.' }); } const panelsRes = await dbPool.query( 'SELECT id, panel_number, image_url, image_prompt, dialogue FROM escola.comics_panels WHERE projeto_id = $1 ORDER BY panel_number ASC;', [id] ); res.json({ project: projectRes.rows[0], panels: panelsRes.rows || [] }); } catch (error) { console.error('Erro ao obter projeto de quadrinhos:', error); res.status(500).json({ error: 'Erro ao obter projeto de quadrinhos.' }); } }); // 3. Salvar / Atualizar projeto app.post('/api/comics/projects', requireAuth, async (req, res) => { const usuarioId = req.userId || req.user?.id || '00000000-0000-0000-0000-000000000000'; const { id, titulo, tema, cenario, proporcao, character_description_global, character_description_english, panels } = req.body; const safeTitulo = (titulo || '').trim() || 'Minha História em Quadrinhos'; const safeTema = (tema || '').trim() || safeTitulo; const safeCenario = (cenario || '').trim() || 'no parque de diversões colorido'; const safeProporcao = proporcao || '16:9'; const safeCharGlobal = typeof character_description_global === 'string' ? character_description_global : JSON.stringify(character_description_global || []); const safeCharEng = character_description_english || ''; let client = null; try { client = await dbPool.connect(); await client.query('BEGIN'); let projetoId = id; if (projetoId) { // Verificar se o projeto existe const check = await client.query( 'SELECT id FROM escola.projetos_comics WHERE id = $1;', [projetoId] ); if (check.rows.length === 0) { projetoId = null; // Não existe no banco, criar como novo } } if (projetoId) { // Atualizar metadados await client.query( `UPDATE escola.projetos_comics SET titulo = $1, tema = $2, cenario = $3, proporcao = $4, character_description_global = $5, character_description_english = $6, updated_at = NOW() WHERE id = $7;`, [safeTitulo, safeTema, safeCenario, safeProporcao, safeCharGlobal, safeCharEng, projetoId] ); } else { // Criar novo projeto const insertProj = await client.query( `INSERT INTO escola.projetos_comics (usuario_id, titulo, tema, cenario, proporcao, character_description_global, character_description_english) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id;`, [usuarioId, safeTitulo, safeTema, safeCenario, safeProporcao, safeCharGlobal, safeCharEng] ); projetoId = insertProj.rows[0].id; } // Atualizar painéis: remover antigos se existiam e salvar novos await client.query('DELETE FROM escola.comics_panels WHERE projeto_id = $1;', [projetoId]); if (panels && Array.isArray(panels)) { for (const p of panels) { await client.query( `INSERT INTO escola.comics_panels (projeto_id, panel_number, image_url, image_prompt, dialogue) VALUES ($1, $2, $3, $4, $5);`, [projetoId, p.panel_number || 1, p.image_url || p.imageUrl || '', p.image_prompt || '', p.dialogue || ''] ); } } await client.query('COMMIT'); res.json({ success: true, id: projetoId, message: 'Projeto salvo com sucesso!' }); } catch (error) { if (client) { try { await client.query('ROLLBACK'); } catch (rbErr) { console.warn('Erro ao executar ROLLBACK:', rbErr.message); } } console.error('Erro ao salvar projeto de quadrinhos:', error); res.status(500).json({ error: 'Erro ao salvar projeto: ' + error.message }); } finally { if (client) { client.release(); } } }); // 4. Excluir projeto app.delete('/api/comics/projects/:id', requireAuth, async (req, res) => { const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000'; const { id } = req.params; try { const result = await dbPool.query( 'DELETE FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2 RETURNING id;', [id, usuarioId] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Projeto não encontrado ou sem permissão para exclusão.' }); } res.json({ success: true, message: 'Projeto excluído com sucesso!' }); } catch (error) { console.error('Erro ao excluir projeto de quadrinhos:', error); res.status(500).json({ error: 'Erro ao excluir projeto de quadrinhos.' }); } }); // ============================================================ // ROTAS DO VIDEOMIND (TRANSCRIÇÃO E ANÁLISE DE VÍDEO) // ============================================================ function getYouTubeId(url) { const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=|live\/|shorts\/)([^#\&\?]*).*/; const match = url.match(regExp); return (match && match[2].length === 11) ? match[2] : null; } app.post('/api/videomind/analyze', requireAuth, async (req, res) => { const { url, lines, pastedTranscript } = req.body; if (!url) { return res.status(400).json({ error: 'A URL do vídeo é obrigatória.' }); } const videoId = getYouTubeId(url); if (!videoId) { return res.status(400).json({ error: 'A URL inserida não é uma URL válida do YouTube.' }); } const numLines = parseInt(lines) || 20; let transcriptText = ''; let metadata = { title: 'Vídeo do YouTube', author: 'Desconhecido', thumbnail: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg` }; try { // Tenta obter metadados via oEmbed oficial do YouTube const oEmbedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; const oEmbedResp = await fetch(oEmbedUrl); if (oEmbedResp.ok) { const oEmbedData = await oEmbedResp.json(); metadata.title = oEmbedData.title || metadata.title; metadata.author = oEmbedData.author_name || metadata.author; metadata.thumbnail = oEmbedData.thumbnail_url || metadata.thumbnail; } } catch (metaErr) { console.error('Erro ao buscar metadados do oEmbed:', metaErr); } let isFallbackMode = false; let systemNotice = ''; if (pastedTranscript && pastedTranscript.trim().length > 10) { // Se o usuário colou a transcrição, ignorar a extração automática transcriptText = pastedTranscript.trim(); systemNotice = `> 📝 **Nota do Sistema**: A análise abaixo foi elaborada com base na transcrição manual que você forneceu.\n\n`; } else { try { // Tenta obter legenda const transcriptObj = await YoutubeTranscript.fetchTranscript(videoId); transcriptText = transcriptObj.map(t => t.text).join(' '); } catch (transcErr) { console.warn(`[VideoMind] Erro ao obter legenda para o vídeo ${videoId}:`, transcErr.message); isFallbackMode = true; if (transcErr.message && transcErr.message.toLowerCase().includes('disabled')) { transcriptText = `[Aviso: O autor deste vídeo desativou as legendas ou elas ainda não foram geradas pelo YouTube. Por favor, analise e descreva os objetivos pedagógicos com base estritamente no título do vídeo, canal do autor e no seu vasto conhecimento pedagógico sobre o tema indicado no título]`; systemNotice = `> ⚠️ **Nota do Sistema**: Este vídeo não possui legendas disponíveis (foram desativadas pelo autor ou ainda não geradas pelo YouTube). A análise e o resumo abaixo foram elaborados a partir do título do vídeo, nome do canal e fundamentação pedagógica correspondente.\n\n`; } else { transcriptText = `[Aviso: Devido a restrições de conexão do YouTube, a legenda de áudio não pôde ser lida diretamente. Por favor, analise e descreva os objetivos pedagógicos com base estritamente no título do vídeo, canal do autor e no seu vasto conhecimento pedagógico sobre o tema indicado no título]`; systemNotice = `> ⚠️ **Nota do Sistema**: Devido a restrições de conexão do YouTube ou indisponibilidade temporária, a transcrição não pôde ser lida. A análise e o resumo abaixo foram elaborados a partir do título do vídeo, canal do autor e fundamentação psicopedagógica correspondente.\n\n`; } } } try { const prompt = `Analise este vídeo do YouTube intitulado "${metadata.title}" (Canal: ${metadata.author}): ID do Vídeo no YouTube: ${videoId} Link do vídeo: https://www.youtube.com/watch?v=${videoId} Conteúdo ou descrição da transcrição: """ ${transcriptText} """ Instruções para o conteúdo de saída: 1. Escreva um RESUMO detalhado do vídeo. Esse resumo precisa ter EXATAMENTE ou APROXIMADAMENTE ${numLines} linhas de texto. Se a transcrição direta não estiver disponível, faça um resumo conceitual com base no título e no tema geral do vídeo com a mesma quantidade de linhas. 2. Faça uma ANÁLISE PEDAGÓGICA COMPLEMENTAR detalhando: - Relevância do conteúdo para fins educacionais. - Indicação de faixa etária/segmento dos alunos recomendado. - Conexão direta com a BNCC (mencione os códigos de competências, habilidades ou campos de experiência correspondentes). - Sugestões práticas de aplicação em sala de aula (atividades pedagógicas, dinâmicas de grupo ou perguntas para debate com os alunos). Retorne a resposta estruturada estritamente em Markdown. Separe as seções usando títulos claros: # Resumo Pedagógico (${numLines} Linhas) [Insira o resumo aqui, ocupando aproximadamente ${numLines} linhas de texto] # Análise Pedagógica e Sugestões Práticas [Insira a análise pedagógica, os pontos da BNCC e as sugestões de atividades práticas aqui]`; let reportText = ''; const systemInstruction = "Você é um pedagogo experiente e especialista em planejamento de aulas no Brasil, alinhado à Base Nacional Comum Curricular (BNCC)."; // Se estiver em fallback, damos preferência ao Gemini pela capacidade de grounding e busca if (isFallbackMode && process.env.GOOGLE_AI_API_KEY) { try { console.log('[VideoMind] Usando fallback com Google Gemini...'); const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`; const response = await fetch(geminiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: `${systemInstruction}\n\nTarefa:\n${prompt}` }] }], generationConfig: { temperature: 0.5, maxOutputTokens: 4000 } }) }); if (response.ok) { const data = await response.json(); reportText = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; } else { const errTxt = await response.text(); console.warn('[VideoMind] Fallback do Gemini respondeu com erro:', errTxt); } } catch (geminiErr) { console.error('[VideoMind] Erro no fallback do Gemini:', geminiErr.message); } } // Se o Gemini não foi usado ou falhou, usamos o callMinimax padrão if (!reportText) { const aiResponse = await callMinimax({ system: systemInstruction, messages: [{ role: 'user', content: prompt }], temperature: 0.5, max_tokens: 3000 }); reportText = aiResponse.text || ''; } res.json({ success: true, metadata, report: systemNotice + reportText }); } catch (err) { console.error('Erro ao processar análise do VideoMind:', err); res.status(500).json({ error: 'Erro ao gerar análise com a IA: ' + err.message }); } }); // ============================================================ // ROTAS DO GARATUJAS (ANALISADOR DE TRAÇADO INFANTIL) // ============================================================ app.post('/api/garatujas/analyze', requireAuth, multerUpload.single('image'), async (req, res) => { const { alunoName, alunoIdade } = req.body; const file = req.file; if (!alunoName) { return res.status(400).json({ error: 'O nome do aluno é obrigatório.' }); } if (!file) { return res.status(400).json({ error: 'A foto do desenho é obrigatória.' }); } try { const fs = require('fs'); const mediaDir = path.join(__dirname, 'public', 'generated-media'); if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } const ext = path.extname(file.originalname) || '.png'; const fileName = `garatuja_${Date.now()}${ext}`; const filePath = path.join(mediaDir, fileName); fs.writeFileSync(filePath, file.buffer); await backupMediaFile(filePath, file.buffer); const imageUrl = `/generated-media/${fileName}`; const base64Image = file.buffer.toString('base64'); const mimeType = file.mimetype || 'image/png'; let analise = ''; if (process.env.GOOGLE_AI_API_KEY) { console.log('[Garatujas] Usando API direta do Google Gemini para análise de imagem...'); const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`; const idadeContexto = alunoIdade ? `de ${alunoIdade} de idade` : '(até 6 anos de idade)'; const promptText = `Você é um psicopedagogo e especialista em desenvolvimento infantil no Brasil. Analise a imagem anexada, que é um desenho/rabisco feito pelo aluno(a) "${alunoName}" ${idadeContexto}. Identifique visualmente os padrões dos traços, uso do espaço, pressão do lápis (se perceptível) e formas criadas. Descreva com base na literatura científica (como Piaget, Luquet, Lowenfeld): 1. O estágio exato do desenho infantil em que a criança se encontra (ex: Garatuja Desordenada, Garatuja Ordenada, Pré-Esquematismo, etc.) justificando com os elementos visuais que você está vendo na imagem. Lembre-se que a criança tem ${idadeContexto}. 2. A análise do desenvolvimento cognitivo e da coordenação motora fina expressa nos traços vistos na imagem. 3. Propostas práticas de estimulação pedagógica personalizadas para o professor aplicar com o aluno "${alunoName}" em sala de aula para avançar para o próximo estágio. Retorne a resposta estruturada em Markdown, usando títulos claros: # Estágio do Desenho e Análise Psicomotora [Sua análise baseada na imagem aqui] # Diagnóstico de Desenvolvimento Cognitivo [Seu diagnóstico aqui] # Sugestões de Atividades para Estimular ${alunoName} [Suas propostas aqui]`; const response = await fetch(geminiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [ { parts: [ { text: promptText }, { inlineData: { mimeType: mimeType, data: base64Image } } ] } ], generationConfig: { temperature: 0.4, maxOutputTokens: 8192 } }) }); if (!response.ok) { const errText = await response.text(); throw new Error(`Erro na API oficial do Gemini: ${errText}`); } const resData = await response.json(); analise = resData.candidates?.[0]?.content?.parts?.[0]?.text || ''; } else { throw new Error('Chave GOOGLE_AI_API_KEY não configurada no servidor.'); } const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; const dbRes = await dbPool.query( `INSERT INTO escola.garatujas (usuario_id, aluno_name, image_url, analise) VALUES ($1, $2, $3, $4) RETURNING id, created_at`, [usuarioId, alunoName, imageUrl, analise] ); res.json({ success: true, id: dbRes.rows[0].id, alunoName, imageUrl, analise, createdAt: dbRes.rows[0].created_at }); } catch (err) { console.error('Erro na análise de garatujas:', err); res.status(500).json({ error: 'Erro ao analisar imagem: ' + err.message }); } }); app.get('/api/garatujas/list', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT id, aluno_name as "alunoName", image_url as "imageUrl", created_at as "createdAt" FROM escola.garatujas WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId] ); res.json(dbRes.rows); } catch (err) { console.error('Erro ao listar garatujas:', err); res.status(500).json({ error: 'Erro ao obter histórico de desenhos.' }); } }); app.get('/api/garatujas/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT id, aluno_name as "alunoName", image_url as "imageUrl", analise, created_at as "createdAt" FROM escola.garatujas WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rows.length === 0) { return res.status(404).json({ error: 'Análise não encontrada.' }); } res.json(dbRes.rows[0]); } catch (err) { console.error('Erro ao buscar garatuja:', err); res.status(500).json({ error: 'Erro ao buscar análise.' }); } }); app.delete('/api/garatujas/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const imageRes = await dbPool.query( `SELECT image_url FROM escola.garatujas WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); const dbRes = await dbPool.query( `DELETE FROM escola.garatujas WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rowCount === 0) { return res.status(404).json({ error: 'Análise não encontrada ou não pertence ao usuário.' }); } if (imageRes.rows.length > 0 && imageRes.rows[0].image_url) { const imgUrl = imageRes.rows[0].image_url; if (imgUrl.startsWith('/generated-media/')) { const fs = require('fs'); const filename = imgUrl.replace('/generated-media/', ''); const filepath = path.join(__dirname, 'public', 'generated-media', filename); if (fs.existsSync(filepath)) { try { fs.unlinkSync(filepath); } catch (unlinkErr) { console.error('Erro ao deletar arquivo de imagem fisicamente:', unlinkErr); } } } } res.json({ success: true, message: 'Análise de desenho deletada com sucesso.' }); } catch (err) { console.error('Erro ao deletar garatuja:', err); res.status(500).json({ error: 'Erro ao deletar análise.' }); } }); // ============================================================ // ROTAS DO BRINCARATIVO (CIRCUITOS PSICOMOTORES E BRINCADEIRAS) // ============================================================ app.post('/api/brincar-ativo/generate', requireAuth, async (req, res) => { const { faixaEtaria, recursos } = req.body; if (!faixaEtaria) { return res.status(400).json({ error: 'A faixa etária é obrigatória.' }); } if (!recursos) { return res.status(400).json({ error: 'A descrição dos recursos é obrigatória.' }); } try { const prompt = `Você é um educador físico e pedagogo especializado na Educação Infantil (crianças de 0 a 6 anos) de acordo com a BNCC no Brasil. Gere uma proposta de circuito psicomotor ou brincadeira ativa adequada para a seguinte faixa etária e com os recursos disponíveis: Faixa Etária: ${faixaEtaria} Recursos e Materiais Disponíveis: ${recursos} Instruções para o conteúdo: 1. Dê um título lúdico e atraente para a atividade. 2. Especifique os Objetivos de Aprendizagem e Desenvolvimento da BNCC (incluindo códigos, como EI01CG02, EI02CG05, etc.). 3. Descreva o Passo a Passo detalhado de como montar o circuito/brincadeira no espaço e como guiar as crianças. 4. Diga o que o professor deve observar durante a atividade para avaliar o desenvolvimento psicomotor dos alunos. Retorne a resposta estritamente em Markdown estruturado: # Atividade: [Nome Lúdico] **Faixa Etária:** ${faixaEtaria} **Campos de Experiência (BNCC):** [Campos e Códigos] ## 📋 Objetivos [Objetivos] ## 🛠️ Como Montar e Conduzir [Passo a passo] ## 🔍 O que Observar (Avaliação Formativa) [Dicas de observação]`; const aiResponse = await callMinimax({ system: "Você é um especialista em educação física e psicomotricidade na educação infantil brasileira.", messages: [{ role: 'user', content: prompt }], temperature: 0.5, max_tokens: 3000 }); const atividade = aiResponse.text || ''; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; const dbRes = await dbPool.query( `INSERT INTO escola.brincar_ativo (usuario_id, faixa_etaria, recursos, atividade) VALUES ($1, $2, $3, $4) RETURNING id, created_at`, [usuarioId, faixaEtaria, recursos, atividade] ); res.json({ success: true, id: dbRes.rows[0].id, faixaEtaria, recursos, atividade, createdAt: dbRes.rows[0].created_at }); } catch (err) { console.error('Erro no BrincarAtivo:', err); res.status(500).json({ error: 'Erro ao gerar atividade com a IA: ' + err.message }); } }); app.get('/api/brincar-ativo/list', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT id, faixa_etaria as "faixaEtaria", recursos, created_at as "createdAt" FROM escola.brincar_ativo WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId] ); res.json(dbRes.rows); } catch (err) { console.error('Erro ao listar atividades:', err); res.status(500).json({ error: 'Erro ao obter lista de atividades.' }); } }); app.get('/api/brincar-ativo/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT id, faixa_etaria as "faixaEtaria", recursos, atividade, created_at as "createdAt" FROM escola.brincar_ativo WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rows.length === 0) { return res.status(404).json({ error: 'Atividade não encontrada.' }); } res.json(dbRes.rows[0]); } catch (err) { console.error('Erro ao buscar atividade:', err); res.status(500).json({ error: 'Erro ao buscar atividade.' }); } }); app.delete('/api/brincar-ativo/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `DELETE FROM escola.brincar_ativo WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rowCount === 0) { return res.status(404).json({ error: 'Atividade não encontrada ou não pertence ao usuário.' }); } res.json({ success: true, message: 'Atividade deletada com sucesso.' }); } catch (err) { console.error('Erro ao deletar brincar-ativo:', err); res.status(500).json({ error: 'Erro ao deletar atividade.' }); } }); // ============================================================ // ============================================================ // ROTAS DO ESTÚDIO DE POESIAS (ESTÚDIO PROFISSIONAL) // ============================================================ app.post('/api/poesias/generate', requireAuth, async (req, res) => { const { faixaEtaria, estrofes, tema, estrutura, estilo, voz, turmaId, gerarAudio } = req.body; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; if (!tema) { return res.status(400).json({ error: 'O tema da poesia é obrigatório.' }); } try { const agentConfig = readAgentConfig(); const agentName = agentConfig.agentName || "PedagogIA"; const promptPoesia = `Você é a ${agentName}, poetisa, linguista e assistente pedagógica da professora Camila Martella Gasparini Reifonas. A professora Camila deseja compor uma poesia pedagógica para seus alunos na Educação Infantil. PARÂMETROS DA POESIA: - Tema / Ideia Central: "${tema}" - Faixa Etária: "${faixaEtaria || 'Crianças pequenas (4 a 5 anos)'}" - Quantidade de Estrofes: ${estrofes || 3} - Estrutura Poética: "${estrutura || 'Quadras (Rimas A-B-A-B)'}" - Estilo Pedagógico: "${estilo || 'Lúdico-cantado e sonoro'}" INSTRUÇÕES DE COMPOSIÇÃO: 1. Componha uma poesia envolvente, com ritmo marcante, musicalidade e rimas expressivas adequadas para a idade informada. 2. Realize uma análise literária detalhada com estimativas percentuais (0-100%) para: - Rima (precisão das terminações sonoras) - Ritmo (cadência e compasso dos versos) - Aliteração (repetição harmoniosa de sons consonantais) - Musicalidade (sonoridade geral) 3. Crie uma versão com guia fonético / marcação de leitura (com acentos tônicos em negrito e marcações de [pausa] para a leitura em voz alta). 4. Mapeie o campo de experiência da BNCC e as habilidades trabalhadas (ex: EI02EF01 / EI03EF02 - Escuta, Fala, Pensamento e Imaginação). 5. Forneça uma vivência pedagógica prática (dica para a professora Camila conduzir sarau, gestos ou dinâmicas). Retorne ESTRITAMENTE um JSON válido (sem texto fora do JSON): { "titulo": "Título Criativo do Poema", "poesia": "Texto completo da poesia com quebras de linha e separação de estrofes.", "esquemaRima": "A-B-A-B", "analiseLiteraria": { "rima": 85, "ritmo": 80, "aliteracao": 65, "musicalidade": 90, "explicacaoMetrica": "Versos regulares com cadência agradável, rimas cruzadas e sonoridade envolvente para contação de histórias." }, "versaoFonetica": "Versão da poesia com sílabas tônicas marcadas e [pausa] para orientação de leitura em sala de aula.", "bnccCampos": "EI02EF01 / EI03EF02 • Escuta, Fala, Pensamento e Imaginação", "dicasPedagogicas": "Sugestão de 2 a 3 linhas para a professora aplicar com as crianças em sala." }`; let jsonResponseText = ''; // 1. Tentar MiniMax M3 try { console.log('[Poesia] Compondo poesia com MiniMax M3...'); const r = await callMinimax({ system: "Você é uma poetisa infantil e linguista pedagógica de excelência no Brasil. Responda estritamente em formato JSON válido.", messages: [{ role: 'user', content: promptPoesia }], temperature: 0.7, max_tokens: 1800 }); jsonResponseText = r.text; } catch (err) { console.error('[Poesia] Erro MiniMax M3:', err.message); } // 2. Fallback Groq if (!jsonResponseText && process.env.GROQ_API_KEY) { try { console.log('[Poesia] Fallback Groq...'); const r = 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 || 'openai/gpt-oss-120b', messages: [{ role: 'user', content: promptPoesia }], temperature: 0.7, max_tokens: 1800, response_format: { type: 'json_object' } }) }); if (r.ok) { const data = await r.json(); jsonResponseText = data.choices?.[0]?.message?.content || ''; } } catch (err) { console.error('[Poesia] Erro Groq:', err.message); } } // 3. Fallback OpenRouter if (!jsonResponseText && process.env.OPENROUTER_API_KEY) { try { console.log('[Poesia] Fallback OpenRouter...'); const r = 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: promptPoesia }], temperature: 0.7, max_tokens: 1800 }) }); if (r.ok) { const data = await r.json(); jsonResponseText = data.choices?.[0]?.message?.content || ''; } } catch (err) { console.error('[Poesia] Erro OpenRouter:', err.message); } } // 4. Fallback Google Gemini if (!jsonResponseText && process.env.GOOGLE_AI_API_KEY) { try { console.log('[Poesia] Fallback Gemini...'); const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`; const response = await fetch(geminiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: promptPoesia }] }], generationConfig: { temperature: 0.7, maxOutputTokens: 2000 } }) }); if (response.ok) { const resData = await response.json(); jsonResponseText = resData.candidates?.[0]?.content?.parts?.[0]?.text || ''; } } catch (err) { console.error('[Poesia] Erro Gemini:', err.message); } } // Parse JSON let poesiaData = {}; try { const cleanJson = jsonResponseText.replace(/```json|```/g, '').trim(); poesiaData = JSON.parse(cleanJson); } catch (parseErr) { console.warn('[Poesia] Erro parse JSON, usando fallback texto:', parseErr.message); poesiaData = { titulo: `Poema: ${tema}`, poesia: jsonResponseText || `Na escola a gente brinca,\nNa escola a gente aprende,\nCom ${tema} todo dia,\nNosso coração se acende!`, esquemaRima: "A-B-C-B", analiseLiteraria: { rima: 80, ritmo: 75, aliteracao: 60, musicalidade: 85, explicacaoMetrica: "Métrica ritmada ideal para contação infantil e memorização em roda." }, versaoFonetica: `Na es**co**la a gente **brin**ca, [pausa]\nNa es**co**la a gente a**pren**de, [pausa]\nCom ${tema} todo **di**a, [pausa]\nNosso cora**ção** se a**cen**de!`, bnccCampos: "EI02EF01 / EI03EF02 • Escuta, Fala, Pensamento e Imaginação", dicasPedagogicas: "Faça gestos de mãos no peito e palmas suaves ao recitar as rimas finais." }; } // GERAÇÃO DE ÁUDIO TTS / RECITADO (MiniMax) let audioUrl = null; if (gerarAudio !== false && process.env.MINIMAX_API_KEY) { try { console.log('[Poesia] Gerando áudio recitado com MiniMax...'); let voiceDesc = 'sweet gentle native brazilian female teacher voice reciting warm nursery poem, calm clear tempo'; if (voz === 'homem') { voiceDesc = 'warm friendly native brazilian male educator voice storytelling poem, soft and clear'; } else if (voz === 'crianca') { voiceDesc = 'cute enthusiastic young brazilian child voice reciting poetry, joyful'; } const audioPrompt = `Spoken poetry recitation, ${voiceDesc}, clear pronunciation, acoustic ambient background`; const lyricsToRecite = (poesiaData.poesia || tema).replace(/[*#]/g, '').trim(); 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: audioPrompt, lyrics: lyricsToRecite.substring(0, 500), audio_setting: { sample_rate: 32000, bitrate: 128000, format: 'mp3' } }) }); if (musicResp.ok) { const mData = await musicResp.json(); const audioHex = mData.data?.audio; if (audioHex) { 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 = `poesia_${Date.now()}.mp3`; const filePath = path.join(mediaDir, fileName); const audioBuffer = Buffer.from(audioHex, 'hex'); fs.writeFileSync(filePath, audioBuffer); await backupMediaFile(filePath, audioBuffer); audioUrl = `/generated-media/${fileName}`; } } } catch (audioErr) { console.error('[Poesia] Erro ao gerar áudio recitado:', audioErr.message); } } // Salvar no Banco PostgreSQL let savedId = null; try { const dbRes = await dbPool.query( `INSERT INTO escola.poesias ( usuario_id, faixa_etaria, estrofes, tema, poesia, estrutura, estilo, audio_url, analise_metrica, versao_fonetica, bncc, turma_id, dicas ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, [ usuarioId, faixaEtaria || 'Crianças pequenas', parseInt(estrofes) || 3, tema, poesiaData.poesia || '', estrutura || 'Quadras', estilo || 'Lúdico-cantado', audioUrl, JSON.stringify(poesiaData.analiseLiteraria || {}), poesiaData.versaoFonetica || '', poesiaData.bnccCampos || '', turmaId || null, poesiaData.dicasPedagogicas || '' ] ); savedId = dbRes.rows[0]?.id; } catch (dbErr) { console.warn('[Poesia] Erro ao salvar colunas estendidas, tentando salvar básico:', dbErr.message); try { const dbRes2 = await dbPool.query( `INSERT INTO escola.poesias (usuario_id, faixa_etaria, estrofes, tema, poesia) VALUES ($1, $2, $3, $4, $5) RETURNING id`, [usuarioId, faixaEtaria || 'Crianças', parseInt(estrofes) || 3, tema, poesiaData.poesia || ''] ); savedId = dbRes2.rows[0]?.id; } catch (err2) { console.error('[Poesia] Erro ao salvar poesia:', err2.message); } } res.json({ success: true, id: savedId, titulo: poesiaData.titulo || `Poema: ${tema}`, poesia: poesiaData.poesia || '', esquemaRima: poesiaData.esquemaRima || 'A-B-A-B', analiseLiteraria: poesiaData.analiseLiteraria || { rima: 80, ritmo: 80, aliteracao: 60, musicalidade: 85 }, versaoFonetica: poesiaData.versaoFonetica || '', bnccCampos: poesiaData.bnccCampos || 'EI02EF01 / EI03EF02 • Escuta, Fala, Pensamento e Imaginação', dicasPedagogicas: poesiaData.dicasPedagogicas || '', audioUrl: audioUrl, tema: tema, faixaEtaria: faixaEtaria, estrutura: estrutura, estilo: estilo }); } catch (err) { console.error('Erro geral no Estúdio de Poesia:', err); res.status(500).json({ error: 'Erro ao compor poesia: ' + err.message }); } }); app.get('/api/poesias/list', requireAuth, async (req, res) => { const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT id, faixa_etaria, estrofes, tema, audio_url, created_at FROM escola.poesias WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId] ); res.json(dbRes.rows); } catch (err) { try { const dbRes2 = await dbPool.query( `SELECT id, faixa_etaria, estrofes, tema, created_at FROM escola.poesias WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId] ); res.json(dbRes2.rows); } catch (err2) { res.status(500).json({ error: 'Erro ao listar histórico de poesias.' }); } } }); app.get('/api/poesias/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `SELECT * FROM escola.poesias WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rows.length === 0) { return res.status(404).json({ error: 'Poesia não encontrada.' }); } res.json(dbRes.rows[0]); } catch (err) { res.status(500).json({ error: 'Erro ao buscar poesia.' }); } }); app.delete('/api/poesias/:id', requireAuth, async (req, res) => { const { id } = req.params; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; try { const dbRes = await dbPool.query( `DELETE FROM escola.poesias WHERE id = $1 AND usuario_id = $2`, [id, usuarioId] ); if (dbRes.rowCount === 0) { return res.status(404).json({ error: 'Poesia não encontrada.' }); } res.json({ success: true, message: 'Poesia deletada com sucesso.' }); } catch (err) { res.status(500).json({ error: 'Erro ao deletar poesia.' }); } }); // ============================================================ // ROTAS DO CRIAR CARTAZES // ============================================================ app.post('/api/cartazes/generate', requireAuth, async (req, res) => { const { titulo, tema, corFundo, corTexto, layout, promptImagem, tamanho, orientacao } = req.body; const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000'; if (!titulo || !tema || !layout) { return res.status(400).json({ error: 'Título, tema e layout são obrigatórios.' }); } try { const agentConfig = readAgentConfig(); const agentName = agentConfig.agentName || "PedagogIA"; // 1. Gerar Conteúdo do Cartaz const posterPrompt = `Você é a ${agentName}, assistente pedagógica da professora Camila Martella Gasparini Reifonas. A professora Camila quer criar um Cartaz para sala de aula da Educação Infantil. Detalhes do Cartaz: - Título: "${titulo}" - Tema / Foco Pedagógico: "${tema}" - Tipo de Cartaz: "${layout}" (educativo, informativo, conscientizacao, cultural) - Tamanho: "${tamanho || 'A4'}" - Orientação: "${orientacao === 'paisagem' ? 'Paisagem/Horizontal' : 'Retrato/Vertical'}" Por favor, gere o CONTEÚDO textual e estrutural do cartaz em HTML. REGRAS DE IDIOMA E LOCALIZAÇÃO: - O conteúdo gerado deve ser IMPRETERIVELMENTE em português do Brasil (pt-BR). Não use termos em outros idiomas de forma desnecessária. REGRAS FUNDAMENTAIS E ESTÉTICAS DE DIAGRAMAÇÃO: - O cartaz deve caber ESTRITAMENTE em UMA ÚNICA PÁGINA FÍSICA (Tamanho ${tamanho || 'A4'}, orientação ${orientacao === 'paisagem' ? 'Horizontal/Paisagem' : 'Vertical/Retrato'}). Não extrapole este espaço de forma alguma. - Limite a quantidade de conteúdo: gere no máximo 2 ou 3 cards (divs de conteúdo) no total. Cada card deve ter pouquíssimo texto (no máximo 3 ou 4 itens curtos por card). - Distribua a informação em CARDS (pequenos cartões ou blocos curtos) bem espaçados, parecidos com pôsteres infantis modernos, onde o texto NÃO ocupa uma folha corrida. - Os textos devem ser extremamente curtos, diretos, visuais e fáceis de ler. - Use emojis apropriados para crianças. - Se for "educativo", estruture o conteúdo pedagógico de forma visual (ex: caixas para alfabeto, tabuada, regras gramaticais). - Se for "informativo", destaque avisos gerais e orientações (ex: horários, cardápio, provas) em caixas curtas e legíveis. - Se for "conscientizacao", use frases curtas de impacto sobre campanhas (ex: prevenção, saúde, coleta seletiva), acompanhadas de dicas em pequenos blocos. - Se for "cultural", promova a atividade de lazer, esporte ou feira destacando o evento de forma animada e pontual. - Considere a orientação ${orientacao === 'paisagem' ? 'Paisagem (horizontal)' : 'Retrato (vertical)'} ao organizar o fluxo: para paisagem, os cards devem ficar lado a lado (2 colunas horizontais); para retrato, eles se empilham verticalmente. - Retorne apenas o formato HTML (use
,

,