4425 lines
193 KiB
JavaScript
4425 lines
193 KiB
JavaScript
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;
|
||
|
||
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 = '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);
|
||
fs.writeFileSync(filePath, Buffer.from(audioHex, 'hex'));
|
||
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
|
||
app.post('/api/comics/generate-frame', requireAuth, async (req, res) => {
|
||
const { prompt } = req.body;
|
||
|
||
if (!prompt) {
|
||
return res.status(400).json({ error: 'O prompt de imagem é obrigatório.' });
|
||
}
|
||
|
||
try {
|
||
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
|
||
const genResp = await fetch(`${minmBase}/v1/image_generation`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'image-01',
|
||
prompt: prompt,
|
||
n: 1
|
||
})
|
||
});
|
||
|
||
if (!genResp.ok) {
|
||
const errText = await genResp.text();
|
||
throw new Error(`MiniMax image-01 respondeu erro: ${errText}`);
|
||
}
|
||
|
||
const genData = await genResp.json();
|
||
const imageUrl = genData.data?.image_urls?.[0];
|
||
if (!imageUrl) {
|
||
const msg = genData.base_resp?.status_msg || 'desconhecido';
|
||
throw new Error(`Nenhuma URL de imagem. Status MiniMax: ${msg}`);
|
||
}
|
||
|
||
// Baixar e salvar localmente
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const mediaDir = path.join(__dirname, 'public', 'generated-media');
|
||
if (!fs.existsSync(mediaDir)) {
|
||
fs.mkdirSync(mediaDir, { recursive: true });
|
||
}
|
||
const imgFetch = await fetch(imageUrl);
|
||
if (!imgFetch.ok) {
|
||
throw new Error(`Falha ao baixar imagem: ${imgFetch.statusText}`);
|
||
}
|
||
const imgBuffer = Buffer.from(await imgFetch.arrayBuffer());
|
||
const fileName = `quadrinho_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`;
|
||
const filePath = path.join(mediaDir, fileName);
|
||
fs.writeFileSync(filePath, imgBuffer);
|
||
|
||
res.json({ imageUrl: `/generated-media/${fileName}` });
|
||
} catch (error) {
|
||
console.error('Erro ao gerar quadrinho:', error);
|
||
res.status(500).json({ error: error.message || 'Erro ao gerar ilustração do painel' });
|
||
}
|
||
});
|
||
|
||
// 3. Compilar a apresentação de vídeo usando FFmpeg a partir dos painéis de quadrinhos
|
||
app.post('/api/comics/generate-video', requireAuth, async (req, res) => {
|
||
const { frames, musicSelection, frameDuration } = req.body;
|
||
|
||
if (!frames || !Array.isArray(frames) || frames.length === 0) {
|
||
return res.status(400).json({ error: 'Nenhum painel enviado para geração de vídeo.' });
|
||
}
|
||
|
||
const duration = frameDuration ? parseInt(frameDuration) : 5;
|
||
const musicFile = musicSelection || 'alegre';
|
||
|
||
try {
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { exec } = require('child_process');
|
||
|
||
const mediaDir = path.join(__dirname, 'public', 'generated-media');
|
||
if (!fs.existsSync(mediaDir)) {
|
||
fs.mkdirSync(mediaDir, { recursive: true });
|
||
}
|
||
|
||
// Mapear trilha musical
|
||
let bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'alegre.mp3');
|
||
if (musicFile === 'calma') {
|
||
bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'calma.mp3');
|
||
} else if (musicFile === 'aventura') {
|
||
bgMusicPath = path.join(__dirname, 'public', 'assets', 'audio', 'aventura.mp3');
|
||
}
|
||
|
||
// Criar arquivo de input para o FFmpeg Concat
|
||
const tempFileId = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||
const concatFilePath = path.join(__dirname, `concat_${tempFileId}.txt`);
|
||
|
||
let concatContent = '';
|
||
frames.forEach(frame => {
|
||
const localFilePath = path.join(__dirname, 'public', frame.imageUrl);
|
||
concatContent += `file '${localFilePath}'\nduration ${duration}\n`;
|
||
});
|
||
// O concat do FFmpeg requer repetir o último arquivo no final
|
||
const lastLocalFilePath = path.join(__dirname, 'public', frames[frames.length - 1].imageUrl);
|
||
concatContent += `file '${lastLocalFilePath}'\n`;
|
||
|
||
fs.writeFileSync(concatFilePath, concatContent);
|
||
|
||
const outputFileName = `video_quadrinhos_${tempFileId}.mp4`;
|
||
const outputFilePath = path.join(mediaDir, outputFileName);
|
||
const totalDuration = frames.length * duration;
|
||
|
||
// Comando FFmpeg: concatena as imagens, adiciona o áudio, ajusta o codec para h264/aac e encerra na duração final
|
||
const ffmpegCommand = `ffmpeg -y -f concat -safe 0 -i "${concatFilePath}" -i "${bgMusicPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest -t ${totalDuration} "${outputFilePath}"`;
|
||
|
||
console.log('[FFmpeg] Executando comando de vídeo:', ffmpegCommand);
|
||
|
||
exec(ffmpegCommand, (error, stdout, stderr) => {
|
||
// Remove o arquivo concat temporário
|
||
try {
|
||
fs.unlinkSync(concatFilePath);
|
||
} catch (e) {
|
||
console.error('Falha ao remover arquivo concat temporário:', e);
|
||
}
|
||
|
||
if (error) {
|
||
console.error('Erro ao processar FFmpeg:', error, stderr);
|
||
return res.status(500).json({ error: 'Erro ao gerar o vídeo da apresentação. Como o servidor foi atualizado e reiniciado recentemente, por favor crie/gere a história em quadrinhos novamente para recriar as imagens temporárias antes de exportar o vídeo.' });
|
||
}
|
||
|
||
res.json({ videoUrl: `/generated-media/${outputFileName}` });
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Erro ao compilar vídeo de apresentação:', error);
|
||
res.status(500).json({ error: 'Erro ao gerar o vídeo: ' + error.message });
|
||
}
|
||
});
|
||
|
||
// --- ROTAS DE PROJETOS DE HQ (SALVAR, LISTAR, CARREGAR, EXCLUIR) ---
|
||
|
||
// 1. Listar projetos
|
||
app.get('/api/comics/projects', requireAuth, async (req, res) => {
|
||
const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000';
|
||
try {
|
||
const result = await dbPool.query(
|
||
'SELECT id, titulo, tema, cenario, proporcao, character_description_global, character_description_english, created_at, updated_at FROM escola.projetos_comics WHERE usuario_id = $1 ORDER BY updated_at DESC;',
|
||
[usuarioId]
|
||
);
|
||
res.json(result.rows);
|
||
} catch (error) {
|
||
console.error('Erro ao listar projetos de quadrinhos:', error);
|
||
res.status(500).json({ error: 'Erro ao listar projetos de quadrinhos.' });
|
||
}
|
||
});
|
||
|
||
// 2. Carregar projeto específico
|
||
app.get('/api/comics/projects/:id', requireAuth, async (req, res) => {
|
||
const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000';
|
||
const { id } = req.params;
|
||
try {
|
||
const projectRes = await dbPool.query(
|
||
'SELECT * FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2;',
|
||
[id, usuarioId]
|
||
);
|
||
if (projectRes.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Projeto não encontrado.' });
|
||
}
|
||
|
||
const panelsRes = await dbPool.query(
|
||
'SELECT id, panel_number, image_url, image_prompt, dialogue FROM escola.comics_panels WHERE projeto_id = $1 ORDER BY panel_number ASC;',
|
||
[id]
|
||
);
|
||
|
||
res.json({
|
||
project: projectRes.rows[0],
|
||
panels: panelsRes.rows
|
||
});
|
||
} catch (error) {
|
||
console.error('Erro ao obter projeto de quadrinhos:', error);
|
||
res.status(500).json({ error: 'Erro ao obter projeto de quadrinhos.' });
|
||
}
|
||
});
|
||
|
||
// 3. Salvar / Atualizar projeto
|
||
app.post('/api/comics/projects', requireAuth, async (req, res) => {
|
||
const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000';
|
||
const { id, titulo, tema, cenario, proporcao, character_description_global, character_description_english, panels } = req.body;
|
||
|
||
if (!titulo) {
|
||
return res.status(400).json({ error: 'O título do projeto é obrigatório.' });
|
||
}
|
||
|
||
const client = await dbPool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
|
||
let projetoId = id;
|
||
if (projetoId) {
|
||
// Verificar propriedade
|
||
const check = await client.query(
|
||
'SELECT id FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2;',
|
||
[projetoId, usuarioId]
|
||
);
|
||
if (check.rows.length === 0) {
|
||
throw new Error('Projeto não encontrado para edição.');
|
||
}
|
||
|
||
// Atualizar metadados
|
||
await client.query(
|
||
`UPDATE escola.projetos_comics
|
||
SET titulo = $1, tema = $2, cenario = $3, proporcao = $4, character_description_global = $5, character_description_english = $6, updated_at = NOW()
|
||
WHERE id = $7;`,
|
||
[titulo, tema, cenario, proporcao, character_description_global, character_description_english, projetoId]
|
||
);
|
||
} else {
|
||
// Criar novo projeto
|
||
const insertProj = await client.query(
|
||
`INSERT INTO escola.projetos_comics (usuario_id, titulo, tema, cenario, proporcao, character_description_global, character_description_english)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
RETURNING id;`,
|
||
[usuarioId, titulo, tema, cenario, proporcao, character_description_global, character_description_english]
|
||
);
|
||
projetoId = insertProj.rows[0].id;
|
||
}
|
||
|
||
// Atualizar painéis: remover antigos se existiam e salvar novos
|
||
await client.query('DELETE FROM escola.comics_panels WHERE projeto_id = $1;', [projetoId]);
|
||
|
||
if (panels && Array.isArray(panels)) {
|
||
for (const p of panels) {
|
||
await client.query(
|
||
`INSERT INTO escola.comics_panels (projeto_id, panel_number, image_url, image_prompt, dialogue)
|
||
VALUES ($1, $2, $3, $4, $5);`,
|
||
[projetoId, p.panel_number, p.image_url, p.image_prompt, p.dialogue]
|
||
);
|
||
}
|
||
}
|
||
|
||
await client.query('COMMIT');
|
||
res.json({ success: true, id: projetoId, message: 'Projeto salvo com sucesso!' });
|
||
} catch (error) {
|
||
await client.query('ROLLBACK');
|
||
console.error('Erro ao salvar projeto de quadrinhos:', error);
|
||
res.status(500).json({ error: 'Erro ao salvar projeto: ' + error.message });
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
// 4. Excluir projeto
|
||
app.delete('/api/comics/projects/:id', requireAuth, async (req, res) => {
|
||
const usuarioId = req.userId || '00000000-0000-0000-0000-000000000000';
|
||
const { id } = req.params;
|
||
try {
|
||
const result = await dbPool.query(
|
||
'DELETE FROM escola.projetos_comics WHERE id = $1 AND usuario_id = $2 RETURNING id;',
|
||
[id, usuarioId]
|
||
);
|
||
if (result.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Projeto não encontrado ou sem permissão para exclusão.' });
|
||
}
|
||
res.json({ success: true, message: 'Projeto excluído com sucesso!' });
|
||
} catch (error) {
|
||
console.error('Erro ao excluir projeto de quadrinhos:', error);
|
||
res.status(500).json({ error: 'Erro ao excluir projeto de quadrinhos.' });
|
||
}
|
||
});
|
||
|
||
// ============================================================
|
||
// 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);
|
||
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 POESIA
|
||
// ============================================================
|
||
app.post('/api/poesias/generate', requireAuth, async (req, res) => {
|
||
const { faixaEtaria, estrofes, tema } = req.body;
|
||
if (!faixaEtaria || !estrofes || !tema) {
|
||
return res.status(400).json({ error: 'Faixa etária, quantidade de estrofes e tema são obrigatórios.' });
|
||
}
|
||
|
||
try {
|
||
let poesiaText = '';
|
||
|
||
let idadeInstrucao = '';
|
||
if (faixaEtaria.includes('Bebês') || faixaEtaria.includes('bem pequenas')) {
|
||
idadeInstrucao = '- NÍVEL FÁCIL: Use palavras EXTREMAMENTE simples, do cotidiano infantil. As estrofes devem ser MUITO curtas, contendo poucas palavras por verso, pois são crianças de colo ou maternal. Elas precisam conseguir memorizar e repetir facilmente.';
|
||
} else if (faixaEtaria.includes('pequenas')) {
|
||
idadeInstrucao = '- NÍVEL MÉDIO: Use palavras fáceis e lúdicas, focando em sonoridade. As estrofes podem ter tamanho normal de versos infantis (4 a 5 palavras por verso).';
|
||
} else {
|
||
idadeInstrucao = '- NÍVEL AVANÇADO: Use vocabulário um pouco mais rico, com metáforas leves. Para crianças do Fundamental I, os versos podem ser mais elaborados e trazer reflexões sobre o tema.';
|
||
}
|
||
|
||
const systemInstruction = `Você é um poeta pedagógico infantil especializado em criar poesias ritmadas, sonoras e adequadas para o desenvolvimento infantil no Brasil.
|
||
REGRAS RÍGIDAS:
|
||
- Escreva a poesia para a faixa etária: ${faixaEtaria}
|
||
${idadeInstrucao}
|
||
- A poesia DEVE ter EXATAMENTE ${estrofes} estrofes.
|
||
- O tema da poesia é: "${tema}"
|
||
- Use rimas claras, ritmo marcante (musicalidade) e seja criativo.
|
||
- Sua resposta deve conter APENAS a poesia formatada em Markdown, sem NENHUM comentário adicional, explicação, ou blocos de pensamento.
|
||
- Responda EXCLUSIVAMENTE em Português do Brasil.`;
|
||
|
||
// Utilizando o Gemini diretamente
|
||
if (process.env.GOOGLE_AI_API_KEY) {
|
||
console.log('[Poesia] Usando API direta do 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 }] }],
|
||
generationConfig: { temperature: 0.7, maxOutputTokens: 2000 }
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errText = await response.text();
|
||
throw new Error(`Erro na API oficial do Gemini: ${errText}`);
|
||
}
|
||
|
||
const resData = await response.json();
|
||
poesiaText = resData.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||
|
||
// Remover qualquer tag <think> residual (embora a instrução já previna)
|
||
poesiaText = poesiaText.replace(/<think>[\s\S]*?(?:<\/think>|$)/g, '').trim();
|
||
} else {
|
||
throw new Error('Chave GOOGLE_AI_API_KEY não configurada no servidor.');
|
||
}
|
||
|
||
if (!poesiaText) {
|
||
throw new Error('Nenhuma poesia gerada.');
|
||
}
|
||
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
const dbRes = await dbPool.query(
|
||
`INSERT INTO escola.poesias (usuario_id, faixa_etaria, estrofes, tema, poesia)
|
||
VALUES ($1, $2, $3, $4, $5) RETURNING id, created_at`,
|
||
[usuarioId, faixaEtaria, parseInt(estrofes), tema, poesiaText]
|
||
);
|
||
|
||
res.json({
|
||
success: true,
|
||
id: dbRes.rows[0].id,
|
||
poesia: poesiaText,
|
||
createdAt: dbRes.rows[0].created_at
|
||
});
|
||
|
||
} catch (err) {
|
||
console.error('Erro na geração de poesia:', err);
|
||
res.status(500).json({ error: 'Erro ao gerar 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, created_at FROM escola.poesias WHERE usuario_id = $1 ORDER BY created_at DESC`,
|
||
[usuarioId]
|
||
);
|
||
res.json(dbRes.rows);
|
||
} catch (err) {
|
||
console.error('Erro ao listar poesias:', err);
|
||
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) {
|
||
console.error('Erro ao buscar poesia:', 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 ou não pertence ao usuário.' });
|
||
}
|
||
res.json({ success: true, message: 'Poesia deletada com sucesso.' });
|
||
} catch (err) {
|
||
console.error('Erro ao deletar poesia:', 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 } = 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 || "Kemily";
|
||
|
||
// 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}"
|
||
- Layout Selecionado: "${layout}" (Opções: regras, mural, calendario, livre)
|
||
|
||
Por favor, gere o CONTEÚDO textual estruturado do cartaz.
|
||
REGRAS:
|
||
- Deve ser acolhedor, lúdico e muito bem estruturado.
|
||
- Use emojis apropriados para crianças.
|
||
- Se for layout "regras", crie uma lista de combinados da sala (max 5) de forma positiva (ex: "Cuidar dos nossos brinquedos" ao invés de "Não quebrar os brinquedos").
|
||
- Se for "calendario", estruture áreas para o dia da semana, ajudante do dia e tempo (ensolarado, chuvoso).
|
||
- Se for "mural", crie seções de recados e novidades da semana.
|
||
- Se for "livre", crie um texto poético ou explicativo baseado no tema.
|
||
- Mantenha os textos curtos, visuais e fáceis de ler para crianças ou pais.
|
||
- Retorne apenas o conteúdo em formato HTML simples (use tags como <h1>, <p>, <ul>, <li>, <strong>, <span> e estilos inline simples se necessário) para renderizarmos no preview do cartaz. Não adicione tags <html> ou <body>, apenas o bloco de conteúdo interno.`;
|
||
|
||
let conteudoHtml = '';
|
||
|
||
// Tentar MiniMax M3
|
||
try {
|
||
console.log('[Cartazes] Gerando conteúdo com MiniMax...');
|
||
const result = await callMinimax({
|
||
messages: [{ role: 'user', content: posterPrompt }],
|
||
temperature: 0.7,
|
||
max_tokens: 1500
|
||
});
|
||
conteudoHtml = result.text;
|
||
} catch (err) {
|
||
console.error('[Cartazes] Erro MiniMax:', err.message);
|
||
}
|
||
|
||
// Fallback OpenRouter
|
||
if (!conteudoHtml && process.env.OPENROUTER_API_KEY) {
|
||
try {
|
||
console.log('[Cartazes] 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: posterPrompt }],
|
||
temperature: 0.7,
|
||
max_tokens: 1500
|
||
})
|
||
});
|
||
if (r.ok) {
|
||
const data = await r.json();
|
||
conteudoHtml = data.choices?.[0]?.message?.content || '';
|
||
}
|
||
} catch (err) {
|
||
console.error('[Cartazes] Erro OpenRouter:', err.message);
|
||
}
|
||
}
|
||
|
||
if (!conteudoHtml) {
|
||
conteudoHtml = `<h2>${titulo}</h2><p>Tema: ${tema}</p><p>Não foi possível gerar com IA. Edite o conteúdo livremente.</p>`;
|
||
}
|
||
|
||
// Clean HTML from backticks
|
||
conteudoHtml = conteudoHtml.replace(/```html|```/g, '').trim();
|
||
|
||
// 2. Gerar Imagem Ilustrativa se solicitado
|
||
let generatedImageUrl = null;
|
||
if (promptImagem && promptImagem.trim().length > 3) {
|
||
try {
|
||
console.log('[Cartazes] Gerando ilustração para o cartaz...');
|
||
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: `${promptImagem}. Estilo ilustração de livro infantil de alta qualidade, 3D Pixar, cores suaves, traço limpo, sem texto.`,
|
||
n: 1
|
||
})
|
||
});
|
||
|
||
if (genResp.ok) {
|
||
const genData = await genResp.json();
|
||
const rawImgUrl = genData.data?.image_urls?.[0];
|
||
if (rawImgUrl) {
|
||
const mediaDir = path.join(__dirname, 'public', 'generated-media');
|
||
if (!fs.existsSync(mediaDir)) fs.mkdirSync(mediaDir, { recursive: true });
|
||
|
||
const imgFetch = await fetch(rawImgUrl);
|
||
if (imgFetch.ok) {
|
||
const imgBuffer = Buffer.from(await imgFetch.arrayBuffer());
|
||
const fileName = `cartaz_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`;
|
||
fs.writeFileSync(path.join(mediaDir, fileName), imgBuffer);
|
||
generatedImageUrl = `/generated-media/${fileName}`;
|
||
}
|
||
}
|
||
}
|
||
} catch (imgErr) {
|
||
console.error('[Cartazes] Erro na geração de imagem:', imgErr);
|
||
}
|
||
}
|
||
|
||
// 3. Salvar no Banco
|
||
const dbRes = await dbPool.query(
|
||
`INSERT INTO escola.cartazes (usuario_id, titulo, tema, cor_fundo, cor_texto, layout, conteudo, imagem_url)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at`,
|
||
[usuarioId, titulo, tema, corFundo || '#ffffff', corTexto || '#1e293b', layout, conteudoHtml, generatedImageUrl]
|
||
);
|
||
|
||
res.json({
|
||
success: true,
|
||
id: dbRes.rows[0].id,
|
||
titulo,
|
||
tema,
|
||
corFundo,
|
||
corTexto,
|
||
layout,
|
||
conteudo: conteudoHtml,
|
||
imagemUrl: generatedImageUrl,
|
||
createdAt: dbRes.rows[0].created_at
|
||
});
|
||
|
||
} catch (err) {
|
||
console.error('Erro ao criar cartaz:', err);
|
||
res.status(500).json({ error: 'Erro ao criar cartaz: ' + err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/cartazes/list', requireAuth, async (req, res) => {
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
try {
|
||
const dbRes = await dbPool.query(
|
||
`SELECT id, titulo, tema, cor_fundo as "corFundo", cor_texto as "corTexto", layout, imagem_url as "imagemUrl", created_at as "createdAt"
|
||
FROM escola.cartazes WHERE usuario_id = $1 ORDER BY created_at DESC`,
|
||
[usuarioId]
|
||
);
|
||
res.json(dbRes.rows);
|
||
} catch (err) {
|
||
console.error('Erro ao listar cartazes:', err);
|
||
res.status(500).json({ error: 'Erro ao listar cartazes.' });
|
||
}
|
||
});
|
||
|
||
app.get('/api/cartazes/: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, titulo, tema, cor_fundo as "corFundo", cor_texto as "corTexto", layout, conteudo, imagem_url as "imagemUrl", created_at as "createdAt"
|
||
FROM escola.cartazes WHERE id = $1 AND usuario_id = $2`,
|
||
[id, usuarioId]
|
||
);
|
||
if (dbRes.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Cartaz não encontrado.' });
|
||
}
|
||
res.json(dbRes.rows[0]);
|
||
} catch (err) {
|
||
console.error('Erro ao buscar cartaz:', err);
|
||
res.status(500).json({ error: 'Erro ao buscar cartaz.' });
|
||
}
|
||
});
|
||
|
||
app.delete('/api/cartazes/: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.cartazes WHERE id = $1 AND usuario_id = $2`,
|
||
[id, usuarioId]
|
||
);
|
||
if (dbRes.rowCount === 0) {
|
||
return res.status(404).json({ error: 'Cartaz não encontrado ou não pertence ao usuário.' });
|
||
}
|
||
res.json({ success: true, message: 'Cartaz excluído com sucesso.' });
|
||
} catch (err) {
|
||
console.error('Erro ao deletar cartaz:', err);
|
||
res.status(500).json({ error: 'Erro ao deletar cartaz.' });
|
||
}
|
||
});
|
||
|
||
// ============================================================
|
||
// ROTAS DO MUSICANDO IDEIAS
|
||
// ============================================================
|
||
app.post('/api/musicas/generate', requireAuth, async (req, res) => {
|
||
const { faixaEtaria, estrofes, ritmo, tema } = req.body;
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
if (!faixaEtaria || !estrofes || !ritmo || !tema) {
|
||
return res.status(400).json({ error: 'Faixa etária, estrofes, ritmo e tema são obrigatórios.' });
|
||
}
|
||
|
||
try {
|
||
let idadeInstrucao = '';
|
||
if (faixaEtaria.includes('Bebês') || faixaEtaria.includes('bem pequenas')) {
|
||
idadeInstrucao = '- NÍVEL FÁCIL: Use palavras EXTREMAMENTE simples e curtas. Foco em onomatopeias e repetições.';
|
||
} else {
|
||
idadeInstrucao = '- NÍVEL MÉDIO/AVANÇADO: Vocabulário lúdico, com rimas criativas.';
|
||
}
|
||
|
||
const systemInstruction = `Você é um compositor infantil especializado em criar músicas pedagógicas animadas no Brasil.
|
||
REGRAS:
|
||
- Faixa etária: ${faixaEtaria}
|
||
${idadeInstrucao}
|
||
- Estrofes: ${estrofes}
|
||
- Ritmo musical base: ${ritmo}
|
||
- Tema: "${tema}"
|
||
- A música deve ter musicalidade e métrica próprias do ritmo escolhido (${ritmo}).
|
||
- Responda APENAS a letra da música em Markdown.`;
|
||
|
||
let musicaText = '';
|
||
if (process.env.GOOGLE_AI_API_KEY) {
|
||
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 }] }] })
|
||
});
|
||
if (!response.ok) {
|
||
const txt = await response.text();
|
||
throw new Error(`Gemini Error: ${txt}`);
|
||
}
|
||
const data = await response.json();
|
||
musicaText = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||
}
|
||
|
||
// Limpeza
|
||
musicaText = musicaText.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||
|
||
if (!musicaText) throw new Error('Falha ao gerar música (texto vazio)');
|
||
|
||
const dbRes = await dbPool.query(
|
||
`INSERT INTO escola.musicas (usuario_id, faixa_etaria, estrofes, ritmo, tema, musica) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||
[usuarioId, faixaEtaria, estrofes, ritmo, tema, musicaText]
|
||
);
|
||
|
||
res.json({ id: dbRes.rows[0].id, musica: musicaText });
|
||
} catch (err) {
|
||
console.error('Erro Música:', err);
|
||
res.status(500).json({ error: 'Erro ao gerar música: ' + err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/musicas/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, ritmo, tema, created_at FROM escola.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/musicas/:id', requireAuth, async (req, res) => {
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
try {
|
||
const dbRes = await dbPool.query(`SELECT * FROM escola.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/musicas/:id', requireAuth, async (req, res) => {
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
try {
|
||
const dbRes = await dbPool.query(`DELETE FROM escola.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 INVENTANDO HISTÓRIAS
|
||
// ============================================================
|
||
app.post('/api/historias/generate', requireAuth, async (req, res) => {
|
||
const { faixaEtaria, paragrafos, personagens, estilo, tema } = req.body;
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
if (!faixaEtaria || !paragrafos || !personagens || !estilo || !tema) {
|
||
return res.status(400).json({ error: 'Todos os campos são obrigatórios.' });
|
||
}
|
||
|
||
try {
|
||
let idadeInstrucao = '';
|
||
if (faixaEtaria.includes('Bebês') || faixaEtaria.includes('bem pequenas')) {
|
||
idadeInstrucao = '- NÍVEL FÁCIL: Enredo muito simples, frases curtas, foco em ações diretas.';
|
||
} else {
|
||
idadeInstrucao = '- NÍVEL MÉDIO/AVANÇADO: Enredo com lição de moral, imaginação fluida e bom vocabulário.';
|
||
}
|
||
|
||
const systemInstruction = `Você é um contador de histórias pedagógico focado na educação infantil brasileira.
|
||
REGRAS:
|
||
- Faixa etária: ${faixaEtaria}
|
||
${idadeInstrucao}
|
||
- Tamanho: Exatamente ${paragrafos} parágrafos.
|
||
- Quantidade de personagens: ${personagens}
|
||
- Estilo narrativo: ${estilo} (se Dialogada, inclua falas claras entre os personagens; se Narração Direta, apenas o narrador conta tudo).
|
||
- Tema: "${tema}"
|
||
- Responda APENAS a história em Markdown.`;
|
||
|
||
let historiaText = '';
|
||
if (process.env.GOOGLE_AI_API_KEY) {
|
||
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 }] }] })
|
||
});
|
||
if (!response.ok) {
|
||
const txt = await response.text();
|
||
throw new Error(`Gemini Error: ${txt}`);
|
||
}
|
||
const data = await response.json();
|
||
historiaText = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||
}
|
||
|
||
historiaText = historiaText.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||
|
||
if (!historiaText) throw new Error('Falha ao inventar história (texto vazio)');
|
||
|
||
const dbRes = await dbPool.query(
|
||
`INSERT INTO escola.historias (usuario_id, faixa_etaria, paragrafos, personagens, estilo, tema, historia) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||
[usuarioId, faixaEtaria, paragrafos, personagens, estilo, tema, historiaText]
|
||
);
|
||
|
||
res.json({ id: dbRes.rows[0].id, historia: historiaText });
|
||
} catch (err) {
|
||
console.error('Erro História:', err);
|
||
res.status(500).json({ error: 'Erro ao inventar história: ' + err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/historias/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, estilo, tema, created_at FROM escola.historias 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/historias/:id', requireAuth, async (req, res) => {
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
try {
|
||
const dbRes = await dbPool.query(`SELECT * FROM escola.historias 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/historias/:id', requireAuth, async (req, res) => {
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
try {
|
||
const dbRes = await dbPool.query(`DELETE FROM escola.historias 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 MIND LAB
|
||
// ============================================================
|
||
app.post('/api/mindlab/generate', requireAuth, async (req, res) => {
|
||
const { faixa_etaria, jogo, metodo, foco, duracao, tema } = req.body;
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
|
||
if (!faixa_etaria || !jogo || !metodo || !foco) {
|
||
return res.status(400).json({ error: 'Os campos obrigatórios do planejamento não foram preenchidos.' });
|
||
}
|
||
|
||
try {
|
||
const systemInstruction = `Você é um especialista na metodologia educacional Mente Inovadora (Mind Lab).
|
||
Crie um planejamento de aula detalhado em Markdown com os seguintes parâmetros:
|
||
- Faixa Etária: ${faixa_etaria}
|
||
- Jogo de Raciocínio: ${jogo}
|
||
- Método Metacognitivo Foco: ${metodo}
|
||
- Foco de Transferência (Socioemocional): ${foco}
|
||
- Duração: ${duracao}
|
||
- Tema Contextual: ${tema || 'Nenhum especifico'}
|
||
|
||
Por favor, estruture a aula obrigatoriamente com:
|
||
1. **Abertura (Contextualização):** Como introduzir o método e o tema.
|
||
2. **O Jogo (Aplicação):** Orientações para a mediação durante a partida.
|
||
3. **Reflexão (Mediação Transcendental):** Perguntas provocativas para fazer as crianças refletirem.
|
||
4. **Transferência:** Como conectar o que aprenderam no jogo e o método escolhido com situações reais da vida deles.`;
|
||
|
||
let planejamentoText = '';
|
||
if (process.env.GOOGLE_AI_API_KEY) {
|
||
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 }] }] })
|
||
});
|
||
if (!response.ok) throw new Error('Erro na API Gemini');
|
||
const data = await response.json();
|
||
planejamentoText = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||
}
|
||
|
||
planejamentoText = planejamentoText.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||
|
||
if (!planejamentoText) throw new Error('Falha ao gerar o planejamento.');
|
||
|
||
const dbRes = await dbPool.query(
|
||
`INSERT INTO escola.mindlab (usuario_id, faixa_etaria, jogo, metodo, foco, tema, planejamento) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||
[usuarioId, faixa_etaria, jogo, metodo, foco, tema, planejamentoText]
|
||
);
|
||
|
||
res.json({ id: dbRes.rows[0].id, planejamento: planejamentoText });
|
||
} catch (err) {
|
||
console.error('Erro Mind Lab:', err);
|
||
res.status(500).json({ error: 'Erro ao gerar planejamento Mind Lab: ' + err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/mindlab/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, jogo, metodo, foco, created_at FROM escola.mindlab 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 do Mind Lab.' }); }
|
||
});
|
||
|
||
app.get('/api/mindlab/:id', requireAuth, async (req, res) => {
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
try {
|
||
const dbRes = await dbPool.query(`SELECT * FROM escola.mindlab 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/mindlab/:id', requireAuth, async (req, res) => {
|
||
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
|
||
try {
|
||
const dbRes = await dbPool.query(`DELETE FROM escola.mindlab 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.' }); }
|
||
});
|
||
|
||
// Rota GET para obter configurações do agente
|
||
app.get('/api/agent-config', requireAuth, (req, res) => {
|
||
const config = readAgentConfig();
|
||
res.json(config);
|
||
});
|
||
|
||
// Rota POST para atualizar configurações do agente
|
||
app.post('/api/agent-config', requireAuth, async (req, res) => {
|
||
const { agentName, kemilyAvatarUrl, camilaAvatarUrl, temperaturePreset } = req.body;
|
||
if (!agentName) {
|
||
return res.status(400).json({ error: 'Nome do agente é obrigatório' });
|
||
}
|
||
const config = {
|
||
agentName: agentName.trim(),
|
||
kemilyAvatarUrl: kemilyAvatarUrl || "assets/kemily.png",
|
||
camilaAvatarUrl: camilaAvatarUrl || "assets/camila_prof.png",
|
||
temperaturePreset: temperaturePreset || 'equilibrado'
|
||
};
|
||
await writeAgentConfig(config);
|
||
res.json({ success: true, config });
|
||
});
|
||
|
||
// Rotas de conhecimento adquirido
|
||
app.get('/api/conhecimento', requireAuth, async (req, res) => {
|
||
try {
|
||
const dbRes = await dbPool.query("SELECT id, content, source, added_at as \"addedAt\" FROM escola.conhecimento ORDER BY added_at DESC;");
|
||
res.json({ facts: dbRes.rows });
|
||
} catch (err) {
|
||
console.error('Erro ao listar conhecimento:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/conhecimento', requireAuth, async (req, res) => {
|
||
const { fact, source } = req.body;
|
||
if (!fact) return res.status(400).json({ error: 'Fato é obrigatório' });
|
||
const added = await addKnowledgeFact(fact, source || 'conversa');
|
||
res.json({ success: true, added });
|
||
});
|
||
|
||
app.delete('/api/conhecimento/:id', requireAuth, async (req, res) => {
|
||
await removeKnowledgeFact(parseInt(req.params.id));
|
||
res.json({ success: true });
|
||
});
|
||
|
||
app.put('/api/conhecimento/:id', requireAuth, async (req, res) => {
|
||
const { content } = req.body;
|
||
if (!content) return res.status(400).json({ error: 'Conteúdo é obrigatório' });
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
await dbPool.query("UPDATE escola.conhecimento SET content = $1 WHERE id = $2;", [content, id]);
|
||
res.json({ success: true });
|
||
} catch (err) {
|
||
console.error('Erro ao atualizar conhecimento:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Analisar mensagem final e extrair conhecimento aprendido (chamado pelo frontend)
|
||
app.post('/api/conhecimento/extrair', requireAuth, async (req, res) => {
|
||
const { message } = req.body;
|
||
if (!message) return res.status(400).json({ error: 'Mensagem é obrigatória' });
|
||
|
||
const knowledgeRegex = /\[KNOWLEDGE:\s*([^\]]+)\]/gi;
|
||
const facts = [];
|
||
let match;
|
||
while ((match = knowledgeRegex.exec(message)) !== null) {
|
||
const fact = match[1].trim();
|
||
if (fact && fact.length > 3) {
|
||
const added = await addKnowledgeFact(fact, 'conversa');
|
||
if (added) facts.push(fact);
|
||
}
|
||
}
|
||
|
||
res.json({ success: true, factsFound: facts.length, facts });
|
||
});
|
||
|
||
app.get('/api/temperature-presets', requireAuth, (req, res) => {
|
||
res.json(TEMPERATURE_PRESETS);
|
||
});
|
||
|
||
// ============================================================
|
||
// SISTEMA DE OBSERVAÇÃO POR VOZ
|
||
// ============================================================
|
||
const OBSERVACOES_DIR = path.join(__dirname, 'observacoes');
|
||
const OBS_INDEX_FILE = path.join(OBSERVACOES_DIR, 'index.json');
|
||
|
||
// Garante que diretório de observações existe
|
||
if (!fs.existsSync(OBSERVACOES_DIR)) {
|
||
fs.mkdirSync(OBSERVACOES_DIR, { recursive: true });
|
||
}
|
||
|
||
// Lê índice de observações (mantido apenas para fins de migração inicial)
|
||
function readObsIndex() {
|
||
if (!fs.existsSync(OBS_INDEX_FILE)) return { observations: [] };
|
||
try {
|
||
return JSON.parse(fs.readFileSync(OBS_INDEX_FILE, 'utf8'));
|
||
} catch { return { observations: [] }; }
|
||
}
|
||
|
||
function writeObsIndex(idx) {
|
||
try {
|
||
fs.writeFileSync(OBS_INDEX_FILE, JSON.stringify(idx, null, 2), 'utf8');
|
||
} catch (err) {
|
||
console.error('Erro ao escrever index local:', err);
|
||
}
|
||
}
|
||
|
||
// Inicializar banco de dados e migrar dados se estiver vazio
|
||
async function initDatabase() {
|
||
try {
|
||
// Garantir extensões UUID no banco
|
||
try {
|
||
await dbPool.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
|
||
await dbPool.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";');
|
||
} catch (extErr) {
|
||
console.warn('[Database Init] Aviso ao habilitar extensões UUID (pode requerer privilégios de superusuário):', extErr.message);
|
||
}
|
||
|
||
// 0. Estruturar banco de dados para Multi-Tenancy (Individualização por Professora)
|
||
// Garantir que as tabelas existentes tenham a coluna usuario_id
|
||
await dbPool.query(`
|
||
ALTER TABLE escola.modelos ADD COLUMN IF NOT EXISTS usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
|
||
ALTER TABLE escola.observacoes ADD COLUMN IF NOT EXISTS usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
|
||
ALTER TABLE escola.conhecimento ADD COLUMN IF NOT EXISTS usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
|
||
ALTER TABLE escola.config ADD COLUMN IF NOT EXISTS usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
|
||
`);
|
||
|
||
// Criar tabelas novas de conversas (histórico de chat) e criações (quadrinhos) com usuario_id
|
||
await dbPool.query(`
|
||
-- Conversas
|
||
CREATE TABLE IF NOT EXISTS escola.conversas (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
titulo VARCHAR(255) NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Mensagens
|
||
CREATE TABLE IF NOT EXISTS escola.mensagens (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
conversa_id UUID REFERENCES escola.conversas(id) ON DELETE CASCADE,
|
||
sender VARCHAR(50) NOT NULL, -- 'user' ou 'assistant'
|
||
content TEXT NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Projetos de HQ (Fábrica de Quadrinhos)
|
||
CREATE TABLE IF NOT EXISTS escola.projetos_comics (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
titulo VARCHAR(255) NOT NULL,
|
||
tema TEXT,
|
||
cenario TEXT,
|
||
proporcao VARCHAR(10) DEFAULT '16:9',
|
||
character_description_global TEXT,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Painéis individuais de cada HQ
|
||
CREATE TABLE IF NOT EXISTS escola.comics_panels (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
projeto_id UUID REFERENCES escola.projetos_comics(id) ON DELETE CASCADE,
|
||
panel_number INTEGER NOT NULL,
|
||
image_url TEXT NOT NULL,
|
||
image_prompt TEXT,
|
||
dialogue TEXT,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Biblioteca de personagens persistentes para reuso
|
||
CREATE TABLE IF NOT EXISTS escola.comics_characters (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
nome VARCHAR(100) NOT NULL,
|
||
descricao_fisica_en TEXT NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Garatujas (Analisador de Traçado Infantil)
|
||
CREATE TABLE IF NOT EXISTS escola.garatujas (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
aluno_name VARCHAR(255) NOT NULL,
|
||
image_url TEXT NOT NULL,
|
||
analise TEXT NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- BrincarAtivo (Circuitos Psicomotores e Brincadeiras)
|
||
CREATE TABLE IF NOT EXISTS escola.brincar_ativo (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
faixa_etaria VARCHAR(100) NOT NULL,
|
||
recursos TEXT NOT NULL,
|
||
atividade TEXT NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Poesias (Estúdio de Poesia)
|
||
CREATE TABLE IF NOT EXISTS escola.poesias (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
faixa_etaria VARCHAR(100) NOT NULL,
|
||
estrofes INTEGER NOT NULL,
|
||
tema TEXT NOT NULL,
|
||
poesia TEXT NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Músicas (Musicando Ideias)
|
||
CREATE TABLE IF NOT EXISTS escola.musicas (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
faixa_etaria VARCHAR(100) NOT NULL,
|
||
estrofes INTEGER NOT NULL,
|
||
ritmo VARCHAR(100) NOT NULL,
|
||
tema TEXT NOT NULL,
|
||
musica TEXT NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Planejamento Mind Lab
|
||
CREATE TABLE IF NOT EXISTS escola.mindlab (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
faixa_etaria VARCHAR(100) NOT NULL,
|
||
jogo VARCHAR(100) NOT NULL,
|
||
metodo VARCHAR(100) NOT NULL,
|
||
foco VARCHAR(100) NOT NULL,
|
||
tema TEXT,
|
||
planejamento TEXT NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Estúdio Musical (Áudio MiniMax)
|
||
CREATE TABLE IF NOT EXISTS escola.estudio_musicas (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
voz VARCHAR(50),
|
||
ritmo VARCHAR(100),
|
||
tema TEXT,
|
||
letra TEXT,
|
||
audio_url TEXT NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Histórias (Inventando Histórias)
|
||
CREATE TABLE IF NOT EXISTS escola.historias (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
faixa_etaria VARCHAR(100) NOT NULL,
|
||
paragrafos INTEGER NOT NULL,
|
||
personagens INTEGER NOT NULL,
|
||
estilo VARCHAR(100) NOT NULL,
|
||
tema TEXT NOT NULL,
|
||
historia TEXT NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
|
||
-- Cartazes (Criar Cartazes)
|
||
CREATE TABLE IF NOT EXISTS escola.cartazes (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
usuario_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||
titulo VARCHAR(255) NOT NULL,
|
||
tema VARCHAR(255) NOT NULL,
|
||
cor_fundo VARCHAR(50) NOT NULL,
|
||
cor_texto VARCHAR(50) NOT NULL,
|
||
layout VARCHAR(50) NOT NULL, -- 'mural', 'regras', 'calendario', 'livre'
|
||
conteudo TEXT NOT NULL,
|
||
imagem_url TEXT,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
`);
|
||
|
||
// Garantir que a coluna character_description_english exista na tabela projetos_comics
|
||
await dbPool.query(`ALTER TABLE escola.projetos_comics ADD COLUMN IF NOT EXISTS character_description_english TEXT;`);
|
||
|
||
// 0.1. Garantir que a tabela de modelos exista e tenha dados padrão
|
||
await dbPool.query(`
|
||
CREATE TABLE IF NOT EXISTS escola.modelos (
|
||
id SERIAL PRIMARY KEY,
|
||
nome VARCHAR(255) NOT NULL,
|
||
finalidade TEXT,
|
||
periodicidade VARCHAR(100),
|
||
estrutura TEXT,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
`);
|
||
|
||
const checkModelos = await dbPool.query("SELECT COUNT(*) FROM escola.modelos;");
|
||
if (parseInt(checkModelos.rows[0].count) === 0) {
|
||
console.log('[Database Init] Inserindo modelos/templates padrão de relatório...');
|
||
await dbPool.query(`
|
||
INSERT INTO escola.modelos (nome, finalidade, periodicidade, estrutura) VALUES
|
||
($1, $2, $3, $4),
|
||
($5, $6, $7, $8);
|
||
`, [
|
||
"Relatório Semanal de Desenvolvimento",
|
||
"Acompanhar a evolução cognitiva e social das crianças semanalmente",
|
||
"Semanal",
|
||
`# Relatório Semanal de Desenvolvimento Pedagógico\n\n## 1. Aspectos Cognitivos e Linguagem\n- Como a criança interagiu com os materiais e desafios propostos esta semana.\n- Expressão verbal, argumentação e participação em rodas de conversa.\n\n## 2. Aspectos Socioemocionais e Autonomia\n- Colaboração com os colegas e resolução de pequenos conflitos.\n- Nível de independência na realização das atividades diárias.\n\n## 3. Aspectos Motores e Brincar\n- Coordenação motora ampla e fina durante as brincadeiras e atividades direcionadas.`,
|
||
"Avaliação Semestral Individual",
|
||
"Avaliar o progresso do aluno combinando uma nova introdução global gerada a partir das atividades do semestre com a narrativa individual de desenvolvimento.",
|
||
"Semestral",
|
||
`O relatório final deve ser gerado sem cabeçalhos institucionais ou títulos de seções (como "I. O Eu..."), contendo apenas texto corrido e estruturado em parágrafos pedagógicos formais e acolhedores no estilo da Professora Camila. O relatório deve ser composto por duas partes principais integradas:\n\nPARTE 1: INTRODUÇÃO GERAL E CONTEXTUALIZAÇÃO COLETIVA (Texto Comum da Turma)\n(Instrução para a IA: Com base nas vivências, projetos e dinâmicas coletivas que aparecem no histórico de observações, redija um texto inédito de 4 a 5 parágrafos descrevendo o trabalho pedagógico coletivo do semestre. Não cite nomes de crianças nesta parte. Siga rigorosamente a estrutura de parágrafos abaixo):\n- Parágrafo 1 — Planejamento Pedagógico & Objetivos Gerais da Turma: Apresente os focos pedagógicos do período (desenvolvimento físico, cognitivo, afetivo/emocional) e o trabalho com a convivência harmoniosa, organização dos espaços/materiais e os combinados de rotina.\n- Parágrafo 2 — Vivências Lúdicas e Simbolismo: Descreva como as brincadeiras e interações simbólicas (faz de conta) serviram para as crianças compartilharem emoções, resolverem conflitos, desenvolverem a empatia, a linguagem e o raciocínio lógico.\n- Parágrafo 3 — Contato com a Cultura Escrita: Descreva o contato das crianças com o mundo letrado e a função social da escrita por meio do acesso a diferentes portadores e gêneros textuais (livros, cartazes, listas, receitas, canções, etc.).\n- Parágrafo 4 — Projeto Temático do Semestre: Detalhe o principal projeto de pesquisa da turma no semestre (ex: Identidade e Diversidade, plantio, etc.), descrevendo as investigações, atividades práticas realizadas e os registros/painéis construídos coletivamente.\n- Parágrafo 5 — Corporeidade, Jogos e Desportividade: Descreva o trabalho com jogos (de tabuleiro, confeccionados com recicláveis) e atividades motoras (circuitos, cantigas de roda, parlendas, corda), destacando o estímulo à atenção, cooperação, agilidade e respeito a regras de convivência.\n\n---\n\nPARTE 2: DESENVOLVIMENTO INDIVIDUAL (Texto Personalizado do Aluno)\n(Instrução para a IA: Redija na sequência um texto narrativo e contínuo, focado exclusivamente no aluno específico, baseado nas observações desse estudante. Adote como inspiração de estilo, tom e estrutura os dois exemplos reais abaixo):\n\nEXEMPLOS REAIS DE INSPIRAÇÃO (Alice e Theo):\nExemplo 1:\n"Alice apresentou muitos avanços neste período. Demonstra muita autonomia nas atividades de rotina, organizando seus pertences pessoais, bem como os brinquedos de uso coletivo após o término das vivências lúdicas. Na hora da alimentação, a aluna demonstra segurança ao servir-se e porcionar sua comida sem desperdícios. No parque, interage alegremente com os colegas, preferindo brincar com potes e pazinhas na areia, demonstrando grande companheirismo e compartilhando vivências com seus amigos, em especial com a Mirella e o Natan. Apresenta ótimo desenvolvimento motor, demonstrando equilíbrio ao pedalar os triciclos, participando de gincanas e realizando circuitos com destreza. Na roda de conversa, expressa-se com clareza e demonstra interesse pela leitura e contação de histórias, folheando livros individualmente. Demonstra facilidade em seguir regras e combinados coletivos."\n\nExemplo 2:\n"Theo apresentou muitos avanços neste período. No parque, gosta de brincar com brinquedos de rodas e na areia com seus amigos, em especial o Izack, o Gabriel e o Murilo, interagindo em brincadeiras simbólicas como o jogo do \'zumbi\'. Demonstra bom desenvolvimento motor amplo, realizando circuitos de equilíbrio e gincanas com agilidade. Nas atividades de expressão oral e corporal, participa de cantigas de roda e recitações. Theo demonstra grande interesse pela leitura e pelos livros, solicitando a contação de histórias com frequência. Constrói belas estruturas tridimensionais com blocos e lego, compartilhando brinquedos após intervenção do adulto. Apresenta boa autonomia na alimentação, porcionando sua refeição de forma consciente. Demonstra evolução na organização e no cumprimento de combinados de convivência na roda e na fila, necessitando ocasionalmente do direcionamento de um adulto."\n\nDIRETRIZES DE CONTRUÇÃO DO TEXTO DO ALUNO:\n- Início Obrigatório: Comece o primeiro parágrafo exatamente com a frase: "[Nome do Aluno] apresentou muitos avanços neste período." (Substitua pelo nome do aluno avaliado).\n- Corpo da narrativa: Integre de forma orgânica, fluida e sem subtítulos ou tópicos:\n 1. Autonomia na rotina diária (organização de materiais individuais/coletivos e porcionamento alimentar na escola sem desperdício).\n 2. Brincadeiras e interações sociais (preferências no parque/areia, identificando os melhores amigos entre aspas, ex: "a Mirella").\n 3. Desenvolvimento motor amplo e fino (pular cordas, pedalar triciclos, circuitos, gincanas) e o respeito aos combinados de organização (fila, roda).\n 4. Linguagem e oralidade (vocabulário, clareza verbal, participação em recitações/música e interesse em livros de histórias).\n 5. Criatividade nas atividades diversificadas (lego/blocos 3D, pinturas, desenhos de formas definidas).\n 6. Desafios/Pontos de atenção: Se houver, utilize sempre linguagem pedagógica atenuada (ex: "necessitando de orientação do adulto", "permanece o desafio de", "com intervenções constantes"). Nunca faça críticas diretas.\n- Fechamento Obrigatório: Finalize o relatório com o seguinte parágrafo isolado: "Espero que o/a [Nome do Aluno] continue se interessando bastante pela escola e avançando muito em suas aprendizagens no próximo ano." (Substitua pelo nome do aluno).`
|
||
]);
|
||
}
|
||
|
||
// 1. Sincronizar cache de configurações do agente ou migrar
|
||
const configRes = await dbPool.query("SELECT agent_name, kemily_avatar_url, camila_avatar_url, temperature_preset FROM escola.config WHERE key = 'current';");
|
||
if (configRes.rows.length > 0) {
|
||
agentConfigCache = {
|
||
agentName: configRes.rows[0].agent_name,
|
||
kemilyAvatarUrl: configRes.rows[0].kemily_avatar_url,
|
||
camilaAvatarUrl: configRes.rows[0].camila_avatar_url,
|
||
temperaturePreset: configRes.rows[0].temperature_preset
|
||
};
|
||
console.log('[Database Init] Configurações do agente carregadas do banco de dados:', agentConfigCache.agentName);
|
||
} else {
|
||
console.log('[Database Init] Migrando configurações do agente do arquivo local...');
|
||
let localConfig = {
|
||
agentName: "Kemily",
|
||
kemilyAvatarUrl: "assets/kemily.png",
|
||
camilaAvatarUrl: "assets/camila_prof.png",
|
||
temperaturePreset: "equilibrado"
|
||
};
|
||
try {
|
||
if (fs.existsSync(CONFIG_FILE_PATH)) {
|
||
localConfig = JSON.parse(fs.readFileSync(CONFIG_FILE_PATH, 'utf8'));
|
||
}
|
||
} catch (err) {
|
||
console.error('[Database Init] Erro ao ler agent_config.json local:', err.message);
|
||
}
|
||
agentConfigCache = localConfig;
|
||
await dbPool.query(
|
||
"INSERT INTO escola.config (key, agent_name, kemily_avatar_url, camila_avatar_url, temperature_preset) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key) DO NOTHING;",
|
||
['current', localConfig.agentName, localConfig.kemilyAvatarUrl, localConfig.camilaAvatarUrl, localConfig.temperaturePreset || 'equilibrado']
|
||
);
|
||
}
|
||
|
||
// 2. Verificar/Migrar fatos de conhecimento
|
||
const knowledgeRes = await dbPool.query("SELECT COUNT(*) FROM escola.conhecimento;");
|
||
if (parseInt(knowledgeRes.rows[0].count) === 0) {
|
||
console.log('[Database Init] Migrando conhecimento do arquivo local...');
|
||
let localKnowledge = { facts: [], lastUpdated: null };
|
||
try {
|
||
if (fs.existsSync(KNOWLEDGE_FILE_PATH)) {
|
||
localKnowledge = JSON.parse(fs.readFileSync(KNOWLEDGE_FILE_PATH, 'utf8'));
|
||
}
|
||
} catch (err) {
|
||
console.error('[Database Init] Erro ao ler conhecimento_adquirido.json local:', err.message);
|
||
}
|
||
for (const fact of localKnowledge.facts || []) {
|
||
await dbPool.query(
|
||
"INSERT INTO escola.conhecimento (content, source, added_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;",
|
||
[fact.content, fact.source, fact.addedAt]
|
||
);
|
||
}
|
||
}
|
||
|
||
// 3. Verificar/Migrar observações pedagógicas
|
||
const obsRes = await dbPool.query("SELECT COUNT(*) FROM escola.observacoes;");
|
||
if (parseInt(obsRes.rows[0].count) === 0) {
|
||
console.log('[Database Init] Migrando observações do arquivo local...');
|
||
let localIndex = { observations: [] };
|
||
try {
|
||
if (fs.existsSync(OBS_INDEX_FILE)) {
|
||
localIndex = JSON.parse(fs.readFileSync(OBS_INDEX_FILE, 'utf8'));
|
||
}
|
||
} catch (err) {
|
||
console.error('[Database Init] Erro ao ler index.json local:', err.message);
|
||
}
|
||
for (const obs of localIndex.observations || []) {
|
||
let reportContent = obs.preview;
|
||
try {
|
||
const yearMonth = obs.date.slice(0, 7);
|
||
const filePath = path.join(OBSERVACOES_DIR, yearMonth, obs.filename);
|
||
if (fs.existsSync(filePath)) {
|
||
const rawFile = fs.readFileSync(filePath, 'utf8');
|
||
const parts = rawFile.split('---\n');
|
||
if (parts.length >= 3) {
|
||
reportContent = parts.slice(2).join('---\n').trim();
|
||
} else {
|
||
reportContent = rawFile;
|
||
}
|
||
}
|
||
} catch (fileErr) {
|
||
console.error('[Database Init] Erro ao ler arquivo de relatório:', obs.filename, fileErr.message);
|
||
}
|
||
|
||
await dbPool.query(
|
||
"INSERT INTO escola.observacoes (id, filename, date, time, timestamp, criancas, turma, tags, report) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO NOTHING;",
|
||
[
|
||
obs.id,
|
||
obs.filename,
|
||
obs.date,
|
||
obs.time,
|
||
obs.timestamp,
|
||
JSON.stringify(obs.criancas),
|
||
obs.turma,
|
||
JSON.stringify(obs.tags),
|
||
reportContent
|
||
]
|
||
);
|
||
}
|
||
}
|
||
console.log('[Database Init] Inicialização e migração do banco de dados concluídas com sucesso!');
|
||
} catch (err) {
|
||
console.error('[Database Init] Erro crítico ao inicializar banco de dados:', err);
|
||
}
|
||
}
|
||
|
||
// Rota: POST /api/observacao/transcrever — recebe áudio e transcreve via Whisper
|
||
app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'), async (req, res) => {
|
||
if (!req.file) {
|
||
return res.status(400).json({ error: 'Nenhum áudio recebido' });
|
||
}
|
||
|
||
let ext = 'webm';
|
||
if (req.file.mimetype) {
|
||
if (req.file.mimetype.includes('mp4')) ext = 'mp4';
|
||
else if (req.file.mimetype.includes('ogg')) ext = 'ogg';
|
||
else if (req.file.mimetype.includes('wav')) ext = 'wav';
|
||
else if (req.file.mimetype.includes('mpeg')) ext = 'mp3';
|
||
}
|
||
|
||
try {
|
||
let transcribedText = '';
|
||
const fileBlob = new Blob([req.file.buffer], { type: req.file.mimetype || 'audio/webm' });
|
||
|
||
if (process.env.OPENAI_API_KEY) {
|
||
console.log('[Smart Transcription] Usando OpenAI Whisper...');
|
||
const whisperFormData = new FormData();
|
||
whisperFormData.append('file', fileBlob, `audio.${ext}`);
|
||
whisperFormData.append('model', 'whisper-1');
|
||
whisperFormData.append('language', 'pt');
|
||
whisperFormData.append('response_format', 'text');
|
||
|
||
const whisperRes = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
|
||
},
|
||
body: whisperFormData
|
||
});
|
||
|
||
if (!whisperRes.ok) {
|
||
const errText = await whisperRes.text();
|
||
console.error('OpenAI Whisper error:', errText);
|
||
throw new Error('Erro na transcrição via OpenAI');
|
||
}
|
||
transcribedText = await whisperRes.text();
|
||
} else if (process.env.GROQ_API_KEY) {
|
||
console.log('[Smart Transcription] Usando Groq Whisper...');
|
||
const whisperFormData = new FormData();
|
||
whisperFormData.append('file', fileBlob, `audio.${ext}`);
|
||
whisperFormData.append('model', 'whisper-large-v3');
|
||
whisperFormData.append('language', 'pt');
|
||
whisperFormData.append('response_format', 'text');
|
||
|
||
const whisperRes = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`
|
||
},
|
||
body: whisperFormData
|
||
});
|
||
|
||
if (!whisperRes.ok) {
|
||
const errText = await whisperRes.text();
|
||
console.error('Groq Whisper error:', errText);
|
||
throw new Error('Erro na transcrição via Groq');
|
||
}
|
||
transcribedText = await whisperRes.text();
|
||
} else {
|
||
throw new Error('Nenhuma chave de API configurada para transcrição de áudio (OPENAI_API_KEY ou GROQ_API_KEY).');
|
||
}
|
||
|
||
if (!transcribedText || transcribedText.trim().length < 2) {
|
||
return res.status(400).json({ error: 'O áudio não contém fala identificável.' });
|
||
}
|
||
|
||
res.json({ transcribedText });
|
||
|
||
} catch (err) {
|
||
console.error('Erro transcrever:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Rota: POST /api/observacao/analisar — recebe texto transcrito e gera relatório pedagógico
|
||
app.post('/api/observacao/analisar', requireAuth, async (req, res) => {
|
||
const { transcribedText, turmaContext } = 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 temperaturePreset = agentConfig.temperaturePreset || 'equilibrado';
|
||
const presetDesc = TEMPERATURE_PRESETS[temperaturePreset]?.description || TEMPERATURE_PRESETS['equilibrado'].description;
|
||
const presetLabel = TEMPERATURE_PRESETS[temperaturePreset]?.label || 'Equilibrado';
|
||
const temperatureValue = TEMPERATURE_PRESETS[temperaturePreset]?.value || 0.6;
|
||
|
||
let contextStr = '';
|
||
if (turmaContext && Array.isArray(turmaContext) && turmaContext.length > 0) {
|
||
contextStr = `\nCONTEXTO DA TURMA DA PROFESSORA (IMPORTANTE):\nEsta é a lista atualizada das crianças cadastradas na "Minha Turma".\nATENÇÃO: Se algum nome no áudio corresponder a uma criança listada abaixo, você DEVE incorporar as necessidades ou condições relatadas nela para enriquecer o relatório, especialmente se a criança tiver "especial": true (Educação Especial). Se a criança for especial, a análise da observação deve ser orientada por essa condição e pelo diagnóstico associado.\nLista de Crianças:\n`;
|
||
contextStr += JSON.stringify(turmaContext.map(c => ({
|
||
nome: c.nome,
|
||
apelido: c.apelido,
|
||
especial: c.especial,
|
||
diagnostico_observacoes: c.especialDetalhes
|
||
})), null, 2);
|
||
contextStr += `\n\n`;
|
||
}
|
||
|
||
const reportPrompt = `Você é a ${agentName}, assistente pedagógica da professora Camila Martella Gasparini Reifonas.
|
||
${contextStr}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
|
||
|
||
🎯 DIRETRIZ DE ESTILO DE RESPOSTA (Obrigatório):
|
||
Você foi configurada para atuar no modo: "${presetLabel}".
|
||
Instrução comportamental: ${presetDesc}
|
||
Ajuste a extensão, a objetividade e o nível de detalhes do conteúdo da chave "relatorio" para refletir ESTRITAMENTE essa diretriz de estilo (ex: se "técnico e direto", faça um relatório mais sintético, pontual e breve; se "detalhista", elabore e estenda mais os contextos e reflexões pedagógicas; se "equilibrado", mantenha um tamanho médio).
|
||
|
||
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: temperatureValue,
|
||
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: temperatureValue,
|
||
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: temperatureValue,
|
||
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: temperatureValue,
|
||
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 });
|
||
});
|
||
|
||
app.use((req, res, next) => {
|
||
if (
|
||
req.path === '/login' ||
|
||
req.path === '/login.html' ||
|
||
req.path === '/login.js' ||
|
||
req.path === '/style.css' ||
|
||
req.path === '/manifest.json' ||
|
||
req.path === '/sw.js' ||
|
||
req.path === '/favicon.ico' ||
|
||
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.
|
||
|
||
═══════════════════════════════════════════════════
|
||
DIRETRIZ DE ATENDIMENTO DIRETO E OBJETIVO
|
||
═══════════════════════════════════════════════════
|
||
|
||
Você é inteligente o suficiente para discernir a NATUREZA da solicitação da Camila:
|
||
1. PEDIDOS DIRETOS DE CONTEÚDO: Se ela pedir algo DIRETAMENTE (ex: "me mostre a poesia A Porta de Vinicius de Moraes", "me dê a receita Y", "escreva o texto Z"), você DEVE fornecer EXATAMENTE E IMEDIATAMENTE o conteúdo pedido, sem longas análises biográficas, sem introduções teóricas enormes e sem rodeios. Apenas entregue o material solicitado.
|
||
2. PEDIDOS DE ANÁLISE/REFLEXÃO: Se ela pedir uma ANÁLISE ou CONTEXTUALIZAÇÃO (ex: "analise essa observação", "o que você acha dessa poesia para a educação infantil?", "faça um relatório sobre isso"), aí sim você deve usar todo o seu lado analítico, detalhista e aplicar intensamente o estilo pedagógico.
|
||
RESUMO: Respeite a intenção primária do pedido. Entregue primeiro o que foi efetivamente solicitado.
|
||
|
||
═══════════════════════════════════════════════════
|
||
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 Groq como primário (ultrarrápido para classificação)
|
||
if (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, tentando MiniMax...', groqErr.message);
|
||
}
|
||
}
|
||
|
||
// 2) Fallback: MiniMax M3
|
||
if (!detectedText && 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);
|
||
}
|
||
}
|
||
|
||
// 3) Fallback final: 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 final na OpenRouter:', err.message);
|
||
}
|
||
}
|
||
|
||
if (detectedText) {
|
||
const txt = detectedText.toUpperCase();
|
||
console.log(`[Smart Router] Resposta crua do detector: "${txt}"`);
|
||
|
||
if (txt.includes('IMAGE')) {
|
||
intent = 'IMAGE';
|
||
} else if (txt.includes('AUDIO') || txt.includes('MUSIC')) {
|
||
intent = 'AUDIO';
|
||
} else if (txt.includes('VIDEO')) {
|
||
intent = 'VIDEO';
|
||
} else {
|
||
intent = 'TEXT';
|
||
}
|
||
console.log(`[Smart Router] Intenção detectada para o prompt "${promptText.substring(0, 40)}...": ${intent}`);
|
||
} else {
|
||
console.error('[Smart Router] Nenhum detector respondeu; usando TEXT como fallback.');
|
||
}
|
||
} catch (err) {
|
||
console.error('Erro no classificador de intenções (usando fallback TEXT):', err);
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// FLUXO DE IMAGEM (MiniMax M3 + image-01)
|
||
// ==========================================================================
|
||
if (intent === 'IMAGE') {
|
||
res.setHeader('Content-Type', 'text/event-stream');
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
res.setHeader('Connection', 'keep-alive');
|
||
|
||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Iniciando geração de imagem com o MiniMax...' })}\n\n`);
|
||
|
||
try {
|
||
// Prompt rewriting: pedir ao M3 para enriquecer o prompt em inglês
|
||
let enrichedPrompt = promptText;
|
||
try {
|
||
const r = await callMinimax({
|
||
system: "Reescreva o pedido de imagem do usuário em inglês como um prompt altamente detalhado para geradores de imagem (como Midjourney/Stable Diffusion). Adicione estilo de ilustração infantil fofa, vibrante, adequada para educação infantil, traço limpo, cores harmoniosas. Se a imagem contiver qualquer cartaz, letreiro, texto ou escrita, estes textos devem obrigatoriamente ser escritos em português do Brasil (Brazilian Portuguese), com ortografia correta, sem misturar outros idiomas ou gerar letras ilegíveis. Retorne apenas a tradução/prompt em inglês.",
|
||
messages: [{ role: 'user', content: promptText }],
|
||
temperature: 0.7,
|
||
max_tokens: 200
|
||
});
|
||
enrichedPrompt = r.text || promptText;
|
||
console.log('[Smart Router] Prompt reescrito para imagem (M3):', enrichedPrompt);
|
||
} catch (err) {
|
||
console.error('Erro ao reescrever prompt de imagem via M3:', err.message);
|
||
}
|
||
|
||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Gerando ilustração pedagógica com MiniMax image-01...' })}\n\n`);
|
||
|
||
// 1) Gerar imagem via endpoint nativo da MiniMax
|
||
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
|
||
const genResp = await fetch(`${minmBase}/v1/image_generation`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'image-01',
|
||
prompt: enrichedPrompt,
|
||
n: 1
|
||
})
|
||
});
|
||
|
||
if (!genResp.ok) {
|
||
const errText = await genResp.text();
|
||
throw new Error(`MiniMax image-01 respondeu erro: ${errText}`);
|
||
}
|
||
|
||
const genData = await genResp.json();
|
||
const imageUrl = genData.data?.image_urls?.[0];
|
||
if (!imageUrl) {
|
||
const msg = genData.base_resp?.status_msg || 'desconhecido';
|
||
throw new Error(`Nenhuma URL de imagem. Status MiniMax: ${msg}`);
|
||
}
|
||
|
||
// 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}, singing in Brazilian Portuguese, Brazilian Portuguese vocals, clear Brazilian pronunciation`,
|
||
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 styleDirective = `\n\n🎯 DIRETRIZ DE ESTILO DE RESPOSTA (Obrigatório):\nVocê foi configurada para responder no modo: "${TEMPERATURE_PRESETS[temperaturePreset]?.label || 'Equilibrado'}".\nInstrução comportamental: ${TEMPERATURE_PRESETS[temperaturePreset]?.description || 'Bom para uso geral — nem muito curto, nem muito longo.'}\nSiga rigorosamente este nível de detalhamento, extensão e objetividade em TODAS as suas respostas.\n\n`;
|
||
|
||
const languageDirective = `\n\n⚠️ DIRETRIZ CRÍTICA DE IDIOMA E SAÍDA:
|
||
- Você deve responder EXCLUSIVAMENTE em português do Brasil (Brazilian Portuguese).
|
||
- Nunca inclua justificativas, introduções, pensamentos, comentários ou notas em inglês.
|
||
- Toda a resposta (incluindo qualquer texto lúdico, histórias, explicações ou poemas) deve ser apresentada 100% em português brasileiro, sem misturar outros idiomas.
|
||
- Retorne apenas o resultado final que o usuário solicitou.\n\n`;
|
||
|
||
const dynamicSystemPrompt = {
|
||
role: "system",
|
||
content: styleDirective + languageDirective + KEMILY_SYSTEM_PROMPT.content
|
||
.replace(/Você é a Kemily/g, `Você é a ${agentName}`)
|
||
.replace('INFORMAÇÕES PESSOAIS:', `${knowledgeText}${observationsContext}INFORMAÇÕES PESSOAIS:`) + styleDirective + languageDirective
|
||
};
|
||
|
||
const enrichedMessages = [dynamicSystemPrompt, ...messages];
|
||
|
||
try {
|
||
let chatResponse;
|
||
let providerUsed = 'MiniMax-M3';
|
||
|
||
// 1) Tentar MiniMax M3 (Streaming OpenAI-compatível) para velocidade instantânea
|
||
let useMinimaxStream = false;
|
||
try {
|
||
if (process.env.MINIMAX_API_KEY) {
|
||
console.log('[Smart Router] Chamando MiniMax M3 (streaming OpenAI-compat)...');
|
||
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
|
||
})
|
||
});
|
||
if (chatResponse.ok) {
|
||
useMinimaxStream = true;
|
||
} else {
|
||
console.warn('[Smart Router] M3 streaming status', chatResponse.status, '- caindo no OpenRouter.');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Erro na conexão streaming com MiniMax M3, tentando OpenRouter...', e.message);
|
||
}
|
||
|
||
// 2) Fallback: OpenRouter (streaming SSE, igual ao original)
|
||
if ((!chatResponse || !chatResponse.ok) && !useMinimaxStream && 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();
|
||
});
|