2358 lines
96 KiB
JavaScript
2358 lines
96 KiB
JavaScript
require('dotenv').config();
|
||
const express = require('express');
|
||
const cookieParser = require('cookie-parser');
|
||
const path = require('path');
|
||
const multer = require('multer');
|
||
|
||
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
|
||
});
|
||
|
||
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 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 });
|
||
});
|
||
|
||
// Analisar mensagem final e extrair conhecimento aprendido (chamado pelo frontend)
|
||
app.post('/api/conhecimento/extrair', requireAuth, async (req, res) => {
|
||
const { message } = req.body;
|
||
if (!message) return res.status(400).json({ error: 'Mensagem é obrigatória' });
|
||
|
||
const knowledgeRegex = /\[KNOWLEDGE:\s*([^\]]+)\]/gi;
|
||
const facts = [];
|
||
let match;
|
||
while ((match = knowledgeRegex.exec(message)) !== null) {
|
||
const fact = match[1].trim();
|
||
if (fact && fact.length > 3) {
|
||
const added = await addKnowledgeFact(fact, 'conversa');
|
||
if (added) facts.push(fact);
|
||
}
|
||
}
|
||
|
||
res.json({ success: true, factsFound: facts.length, facts });
|
||
});
|
||
|
||
app.get('/api/temperature-presets', requireAuth, (req, res) => {
|
||
res.json(TEMPERATURE_PRESETS);
|
||
});
|
||
|
||
// ============================================================
|
||
// SISTEMA DE OBSERVAÇÃO POR VOZ
|
||
// ============================================================
|
||
const OBSERVACOES_DIR = path.join(__dirname, 'observacoes');
|
||
const OBS_INDEX_FILE = path.join(OBSERVACOES_DIR, 'index.json');
|
||
|
||
// Garante que diretório de observações existe
|
||
if (!fs.existsSync(OBSERVACOES_DIR)) {
|
||
fs.mkdirSync(OBSERVACOES_DIR, { recursive: true });
|
||
}
|
||
|
||
// Lê índice de observações (mantido apenas para fins de migração inicial)
|
||
function readObsIndex() {
|
||
if (!fs.existsSync(OBS_INDEX_FILE)) return { observations: [] };
|
||
try {
|
||
return JSON.parse(fs.readFileSync(OBS_INDEX_FILE, 'utf8'));
|
||
} catch { return { observations: [] }; }
|
||
}
|
||
|
||
function writeObsIndex(idx) {
|
||
try {
|
||
fs.writeFileSync(OBS_INDEX_FILE, JSON.stringify(idx, null, 2), 'utf8');
|
||
} catch (err) {
|
||
console.error('Erro ao escrever index local:', err);
|
||
}
|
||
}
|
||
|
||
// Inicializar banco de dados e migrar dados se estiver vazio
|
||
async function initDatabase() {
|
||
try {
|
||
// 0. Garantir que a tabela de modelos exista e tenha dados padrão
|
||
await dbPool.query(`
|
||
CREATE TABLE IF NOT EXISTS escola.modelos (
|
||
id SERIAL PRIMARY KEY,
|
||
nome VARCHAR(255) NOT NULL,
|
||
finalidade TEXT,
|
||
periodicidade VARCHAR(100),
|
||
estrutura TEXT,
|
||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||
);
|
||
`);
|
||
|
||
const checkModelos = await dbPool.query("SELECT COUNT(*) FROM escola.modelos;");
|
||
if (parseInt(checkModelos.rows[0].count) === 0) {
|
||
console.log('[Database Init] Inserindo modelos/templates padrão de relatório...');
|
||
await dbPool.query(`
|
||
INSERT INTO escola.modelos (nome, finalidade, periodicidade, estrutura) VALUES
|
||
($1, $2, $3, $4),
|
||
($5, $6, $7, $8);
|
||
`, [
|
||
"Relatório Semanal de Desenvolvimento",
|
||
"Acompanhar a evolução cognitiva e social das crianças semanalmente",
|
||
"Semanal",
|
||
`# Relatório Semanal de Desenvolvimento Pedagógico\n\n## 1. Aspectos Cognitivos e Linguagem\n- Como a criança interagiu com os materiais e desafios propostos esta semana.\n- Expressão verbal, argumentação e participação em rodas de conversa.\n\n## 2. Aspectos Socioemocionais e Autonomia\n- Colaboração com os colegas e resolução de pequenos conflitos.\n- Nível de independência na realização das atividades diárias.\n\n## 3. Aspectos Motores e Brincar\n- Coordenação motora ampla e fina durante as brincadeiras e atividades direcionadas.`,
|
||
"Avaliação Semestral Individual",
|
||
"Avaliar de forma global o progresso do aluno ao fim do semestre",
|
||
"Semestral",
|
||
`# Avaliação Semestral Individual - Educação Infantil\n\n## Introdução\nContextualização geral da adaptação e participação do aluno no semestre.\n\n## I. O Eu, o Outro e o Nós\n- Relações sociais, respeito às regras e empatia.\n\n## II. Corpo, Gestos e Movimentos\n- Expressividade, habilidades motoras e exploração do espaço.\n\n## III. Traços, Sons, Cores e Formas\n- Sensibilidade estética, linguagem artística e musical.\n\n## IV. Escuta, Fala, Pensamento e Imaginação\n- Oralidade, repertório de histórias e início da escrita/grafismo.\n\n## V. Espaços, Tempos, Quantidades, Relações e Transformações\n- Raciocínio lógico-matemático e curiosidade científica.`
|
||
]);
|
||
}
|
||
|
||
// 1. Sincronizar cache de configurações do agente ou migrar
|
||
const configRes = await dbPool.query("SELECT agent_name, kemily_avatar_url, camila_avatar_url, temperature_preset FROM escola.config WHERE key = 'current';");
|
||
if (configRes.rows.length > 0) {
|
||
agentConfigCache = {
|
||
agentName: configRes.rows[0].agent_name,
|
||
kemilyAvatarUrl: configRes.rows[0].kemily_avatar_url,
|
||
camilaAvatarUrl: configRes.rows[0].camila_avatar_url,
|
||
temperaturePreset: configRes.rows[0].temperature_preset
|
||
};
|
||
console.log('[Database Init] Configurações do agente carregadas do banco de dados:', agentConfigCache.agentName);
|
||
} else {
|
||
console.log('[Database Init] Migrando configurações do agente do arquivo local...');
|
||
let localConfig = {
|
||
agentName: "Kemily",
|
||
kemilyAvatarUrl: "assets/kemily.png",
|
||
camilaAvatarUrl: "assets/camila_prof.png",
|
||
temperaturePreset: "equilibrado"
|
||
};
|
||
try {
|
||
if (fs.existsSync(CONFIG_FILE_PATH)) {
|
||
localConfig = JSON.parse(fs.readFileSync(CONFIG_FILE_PATH, 'utf8'));
|
||
}
|
||
} catch (err) {
|
||
console.error('[Database Init] Erro ao ler agent_config.json local:', err.message);
|
||
}
|
||
agentConfigCache = localConfig;
|
||
await dbPool.query(
|
||
"INSERT INTO escola.config (key, agent_name, kemily_avatar_url, camila_avatar_url, temperature_preset) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key) DO NOTHING;",
|
||
['current', localConfig.agentName, localConfig.kemilyAvatarUrl, localConfig.camilaAvatarUrl, localConfig.temperaturePreset || 'equilibrado']
|
||
);
|
||
}
|
||
|
||
// 2. Verificar/Migrar fatos de conhecimento
|
||
const knowledgeRes = await dbPool.query("SELECT COUNT(*) FROM escola.conhecimento;");
|
||
if (parseInt(knowledgeRes.rows[0].count) === 0) {
|
||
console.log('[Database Init] Migrando conhecimento do arquivo local...');
|
||
let localKnowledge = { facts: [], lastUpdated: null };
|
||
try {
|
||
if (fs.existsSync(KNOWLEDGE_FILE_PATH)) {
|
||
localKnowledge = JSON.parse(fs.readFileSync(KNOWLEDGE_FILE_PATH, 'utf8'));
|
||
}
|
||
} catch (err) {
|
||
console.error('[Database Init] Erro ao ler conhecimento_adquirido.json local:', err.message);
|
||
}
|
||
for (const fact of localKnowledge.facts || []) {
|
||
await dbPool.query(
|
||
"INSERT INTO escola.conhecimento (content, source, added_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;",
|
||
[fact.content, fact.source, fact.addedAt]
|
||
);
|
||
}
|
||
}
|
||
|
||
// 3. Verificar/Migrar observações pedagógicas
|
||
const obsRes = await dbPool.query("SELECT COUNT(*) FROM escola.observacoes;");
|
||
if (parseInt(obsRes.rows[0].count) === 0) {
|
||
console.log('[Database Init] Migrando observações do arquivo local...');
|
||
let localIndex = { observations: [] };
|
||
try {
|
||
if (fs.existsSync(OBS_INDEX_FILE)) {
|
||
localIndex = JSON.parse(fs.readFileSync(OBS_INDEX_FILE, 'utf8'));
|
||
}
|
||
} catch (err) {
|
||
console.error('[Database Init] Erro ao ler index.json local:', err.message);
|
||
}
|
||
for (const obs of localIndex.observations || []) {
|
||
let reportContent = obs.preview;
|
||
try {
|
||
const yearMonth = obs.date.slice(0, 7);
|
||
const filePath = path.join(OBSERVACOES_DIR, yearMonth, obs.filename);
|
||
if (fs.existsSync(filePath)) {
|
||
const rawFile = fs.readFileSync(filePath, 'utf8');
|
||
const parts = rawFile.split('---\n');
|
||
if (parts.length >= 3) {
|
||
reportContent = parts.slice(2).join('---\n').trim();
|
||
} else {
|
||
reportContent = rawFile;
|
||
}
|
||
}
|
||
} catch (fileErr) {
|
||
console.error('[Database Init] Erro ao ler arquivo de relatório:', obs.filename, fileErr.message);
|
||
}
|
||
|
||
await dbPool.query(
|
||
"INSERT INTO escola.observacoes (id, filename, date, time, timestamp, criancas, turma, tags, report) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO NOTHING;",
|
||
[
|
||
obs.id,
|
||
obs.filename,
|
||
obs.date,
|
||
obs.time,
|
||
obs.timestamp,
|
||
JSON.stringify(obs.criancas),
|
||
obs.turma,
|
||
JSON.stringify(obs.tags),
|
||
reportContent
|
||
]
|
||
);
|
||
}
|
||
}
|
||
console.log('[Database Init] Inicialização e migração do banco de dados concluídas com sucesso!');
|
||
} catch (err) {
|
||
console.error('[Database Init] Erro crítico ao inicializar banco de dados:', err);
|
||
}
|
||
}
|
||
|
||
// Rota: POST /api/observacao/transcrever — recebe áudio e transcreve via Whisper
|
||
app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'), async (req, res) => {
|
||
if (!req.file) {
|
||
return res.status(400).json({ error: 'Nenhum áudio recebido' });
|
||
}
|
||
|
||
let ext = 'webm';
|
||
if (req.file.mimetype) {
|
||
if (req.file.mimetype.includes('mp4')) ext = 'mp4';
|
||
else if (req.file.mimetype.includes('ogg')) ext = 'ogg';
|
||
else if (req.file.mimetype.includes('wav')) ext = 'wav';
|
||
else if (req.file.mimetype.includes('mpeg')) ext = 'mp3';
|
||
}
|
||
|
||
try {
|
||
let transcribedText = '';
|
||
const fileBlob = new Blob([req.file.buffer], { type: req.file.mimetype || 'audio/webm' });
|
||
|
||
if (process.env.OPENAI_API_KEY) {
|
||
console.log('[Smart Transcription] Usando OpenAI Whisper...');
|
||
const whisperFormData = new FormData();
|
||
whisperFormData.append('file', fileBlob, `audio.${ext}`);
|
||
whisperFormData.append('model', 'whisper-1');
|
||
whisperFormData.append('language', 'pt');
|
||
whisperFormData.append('response_format', 'text');
|
||
|
||
const whisperRes = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
|
||
},
|
||
body: whisperFormData
|
||
});
|
||
|
||
if (!whisperRes.ok) {
|
||
const errText = await whisperRes.text();
|
||
console.error('OpenAI Whisper error:', errText);
|
||
throw new Error('Erro na transcrição via OpenAI');
|
||
}
|
||
transcribedText = await whisperRes.text();
|
||
} else if (process.env.GROQ_API_KEY) {
|
||
console.log('[Smart Transcription] Usando Groq Whisper...');
|
||
const whisperFormData = new FormData();
|
||
whisperFormData.append('file', fileBlob, `audio.${ext}`);
|
||
whisperFormData.append('model', 'whisper-large-v3');
|
||
whisperFormData.append('language', 'pt');
|
||
whisperFormData.append('response_format', 'text');
|
||
|
||
const whisperRes = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`
|
||
},
|
||
body: whisperFormData
|
||
});
|
||
|
||
if (!whisperRes.ok) {
|
||
const errText = await whisperRes.text();
|
||
console.error('Groq Whisper error:', errText);
|
||
throw new Error('Erro na transcrição via Groq');
|
||
}
|
||
transcribedText = await whisperRes.text();
|
||
} else {
|
||
throw new Error('Nenhuma chave de API configurada para transcrição de áudio (OPENAI_API_KEY ou GROQ_API_KEY).');
|
||
}
|
||
|
||
if (!transcribedText || transcribedText.trim().length < 2) {
|
||
return res.status(400).json({ error: 'O áudio não contém fala identificável.' });
|
||
}
|
||
|
||
res.json({ transcribedText });
|
||
|
||
} catch (err) {
|
||
console.error('Erro transcrever:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Rota: POST /api/observacao/analisar — recebe texto transcrito e gera relatório pedagógico
|
||
app.post('/api/observacao/analisar', requireAuth, async (req, res) => {
|
||
const { transcribedText } = req.body;
|
||
if (!transcribedText || transcribedText.trim().length < 5) {
|
||
return res.status(400).json({ error: 'Texto muito curto ou inexistente para análise.' });
|
||
}
|
||
|
||
try {
|
||
const agentConfig = readAgentConfig();
|
||
const agentName = agentConfig.agentName || "Kemily";
|
||
|
||
const reportPrompt = `Você é a ${agentName}, assistente pedagógica da professora Camila Martella Gasparini Reifonas.
|
||
|
||
Ela acabou de registrar uma observação sobre seus alunos da educação infantil. Sua tarefa é ANALISAR o texto e retornar UM OBJETO JSON (sem markdown, sem código, apenas o JSON puro) com:
|
||
|
||
{
|
||
"criancas": ["lista de nomes de crianças mencionadas ou 'null' se não identificar nenhuma"],
|
||
"turma": "turma mencionada ou 'não informada'",
|
||
"tags": ["tag1", "tag2"] (social, cognitivo, motor, linguagem, emocional, colaborativo, autónomo),
|
||
"relatorio": "...markdown estruturado do relatório pedagógico..."
|
||
}
|
||
|
||
REGRAS:
|
||
- Identifique crianças pelo nome próprio mencionado na fala/texto
|
||
- Se não conseguir identificar nomes, use "criancas": null
|
||
- Tags: escolha entre social, cognitivo, motor, linguagem, emocional, colaborativo, autónomo
|
||
- Relatório em português, linguagem formal-pedagógica, seções claras em **markdown**
|
||
- Foque em evidências observáveis
|
||
- Máximo 120 palavras no relatório
|
||
|
||
OBSERVAÇÃO REGISTRADA:
|
||
"""
|
||
${transcribedText}
|
||
"""
|
||
|
||
Retorne APENAS o JSON válido:`;
|
||
|
||
let rawContent = '';
|
||
|
||
// Tentar OpenRouter
|
||
try {
|
||
console.log('[Smart Report] Tentando gerar relatório via OpenRouter...');
|
||
const reportRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano',
|
||
messages: [{ role: 'user', content: reportPrompt }],
|
||
temperature: 0.5,
|
||
max_tokens: 1500
|
||
})
|
||
});
|
||
|
||
if (reportRes.ok) {
|
||
const reportData = await reportRes.json();
|
||
rawContent = reportData.choices?.[0]?.message?.content || '';
|
||
} else {
|
||
console.warn('[Smart Report] Falha no OpenRouter:', await reportRes.text());
|
||
}
|
||
} catch (err) {
|
||
console.error('[Smart Report] Erro de conexão com OpenRouter:', err.message);
|
||
}
|
||
|
||
// Fallback para Groq se OpenRouter falhar
|
||
if (!rawContent && process.env.GROQ_API_KEY) {
|
||
try {
|
||
console.log('[Smart Report] Usando Groq como fallback para gerar relatório...');
|
||
const reportRes = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model: process.env.GROQ_MODEL || 'llama-3.3-70b-versatile',
|
||
messages: [{ role: 'user', content: reportPrompt }],
|
||
temperature: 0.5,
|
||
max_tokens: 1500
|
||
})
|
||
});
|
||
|
||
if (reportRes.ok) {
|
||
const reportData = await reportRes.json();
|
||
rawContent = reportData.choices?.[0]?.message?.content || '';
|
||
} else {
|
||
console.warn('[Smart Report] Falha no Groq:', await reportRes.text());
|
||
}
|
||
} catch (err) {
|
||
console.error('[Smart Report] Erro de conexão com Groq:', err.message);
|
||
}
|
||
}
|
||
|
||
// Fallback para Google Gemini se OpenRouter e Groq falharem
|
||
if (!rawContent && process.env.GOOGLE_AI_API_KEY) {
|
||
try {
|
||
console.log('[Smart Report] Usando Google Gemini como fallback para gerar relatório...');
|
||
const reportRes = await fetch('https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.GOOGLE_AI_API_KEY}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model: process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash',
|
||
messages: [{ role: 'user', content: reportPrompt }],
|
||
temperature: 0.5,
|
||
max_tokens: 1500
|
||
})
|
||
});
|
||
|
||
if (reportRes.ok) {
|
||
const reportData = await reportRes.json();
|
||
rawContent = reportData.choices?.[0]?.message?.content || '';
|
||
} else {
|
||
console.warn('[Smart Report] Falha no Google Gemini:', await reportRes.text());
|
||
}
|
||
} catch (err) {
|
||
console.error('[Smart Report] Erro de conexão com Google Gemini:', err.message);
|
||
}
|
||
}
|
||
|
||
// Fallback para MiniMax se OpenRouter, Groq e Google Gemini falharem
|
||
if (!rawContent && process.env.MINIMAX_API_KEY) {
|
||
try {
|
||
console.log('[Smart Report] Usando MiniMax-M2.7 como fallback para gerar relatório...');
|
||
const reportRes = await fetch('https://api.minimaxi.chat/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'MiniMax-M2.7',
|
||
messages: [{ role: 'user', content: reportPrompt }],
|
||
temperature: 0.5,
|
||
max_tokens: 1500
|
||
})
|
||
});
|
||
|
||
if (reportRes.ok) {
|
||
const reportData = await reportRes.json();
|
||
rawContent = reportData.choices?.[0]?.message?.content || '';
|
||
} else {
|
||
console.warn('[Smart Report] Falha no MiniMax:', await reportRes.text());
|
||
}
|
||
} catch (err) {
|
||
console.error('[Smart Report] Erro de conexão com MiniMax:', err.message);
|
||
}
|
||
}
|
||
|
||
if (!rawContent) {
|
||
throw new Error('Falha ao processar o relatório pedagógico em todos os provedores de IA (OpenRouter, Groq, Google Gemini e MiniMax-M2.7).');
|
||
}
|
||
|
||
// Parse JSON da resposta
|
||
let parsedData = { criancas: null, turma: 'não informada', tags: [], relatorio: rawContent };
|
||
try {
|
||
const firstBrace = rawContent.indexOf('{');
|
||
const lastBrace = rawContent.lastIndexOf('}');
|
||
if (firstBrace !== -1 && lastBrace !== -1) {
|
||
const jsonString = rawContent.substring(firstBrace, lastBrace + 1);
|
||
const parsed = JSON.parse(jsonString);
|
||
parsedData = {
|
||
criancas: parsed.criancas || null,
|
||
turma: parsed.turma || 'não informada',
|
||
tags: parsed.tags || [],
|
||
relatorio: parsed.relatorio || rawContent
|
||
};
|
||
} else {
|
||
throw new Error('Nenhum JSON encontrado na resposta da IA');
|
||
}
|
||
} catch (parseErr) {
|
||
console.warn('Não foi possível parsear JSON da resposta da IA:', parseErr.message);
|
||
parsedData.relatorio = rawContent;
|
||
}
|
||
|
||
res.json(parsedData);
|
||
|
||
} catch (err) {
|
||
console.error('Erro ao analisar observação:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Rota: POST /api/observacao/salvar — salva relatório em .md
|
||
app.post('/api/observacao/salvar', requireAuth, async (req, res) => {
|
||
const { report, criancas, turma, tags } = req.body;
|
||
if (!report) return res.status(400).json({ error: 'Relatório vazio' });
|
||
|
||
const now = new Date();
|
||
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||
const dateStr = now.toISOString().split('T')[0];
|
||
const timeStr = now.toTimeString().slice(0, 5).replace(':', 'h') + 'min';
|
||
const timestamp = now.toISOString();
|
||
const id = Date.now();
|
||
const filename = `${dateStr}_${timeStr}.md`;
|
||
|
||
// Salvar no Banco (Supabase)
|
||
try {
|
||
await dbPool.query(
|
||
"INSERT INTO escola.observacoes (id, filename, date, time, timestamp, criancas, turma, tags, report) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);",
|
||
[id, filename, dateStr, timeStr, timestamp, JSON.stringify(criancas), turma || 'não informada', JSON.stringify(tags), report]
|
||
);
|
||
} catch (dbErr) {
|
||
console.error('Erro ao salvar observação no banco:', dbErr);
|
||
}
|
||
|
||
// Monta markdown com frontmatter
|
||
const mdContent = `---\ndata: "${dateStr}"\nhora: "${timeStr}"\ntimestamp: "${timestamp}"\ncriancas: ${JSON.stringify(criancas)}\nturma: "${turma || 'não informada'}"\ntags: ${JSON.stringify(tags)}\n---\n\n${report}\n`;
|
||
const monthDir = path.join(OBSERVACOES_DIR, yearMonth);
|
||
if (!fs.existsSync(monthDir)) fs.mkdirSync(monthDir, { recursive: true });
|
||
|
||
const filePath = path.join(monthDir, filename);
|
||
fs.writeFileSync(filePath, mdContent, 'utf8');
|
||
|
||
// Também mantemos a escrita no index.json local para fins de compatibilidade
|
||
try {
|
||
const idx = readObsIndex();
|
||
idx.observations.unshift({
|
||
id,
|
||
filename,
|
||
date: dateStr,
|
||
time: timeStr,
|
||
timestamp,
|
||
criancas: criancas || null,
|
||
turma: turma || 'não informada',
|
||
tags: tags || [],
|
||
preview: report.slice(0, 120) + '...'
|
||
});
|
||
writeObsIndex(idx);
|
||
} catch (idxErr) {
|
||
console.error('Erro ao salvar índice local:', idxErr);
|
||
}
|
||
|
||
res.json({ success: true, filename, id });
|
||
});
|
||
|
||
// ============================================================
|
||
// CRUD DE MODELOS DE RELATÓRIO (TEMPLATES) & COMPILAÇÃO
|
||
// ============================================================
|
||
|
||
// Listar todos os modelos
|
||
app.get('/api/modelos', requireAuth, async (req, res) => {
|
||
try {
|
||
const dbRes = await dbPool.query("SELECT * FROM escola.modelos ORDER BY nome ASC;");
|
||
res.json(dbRes.rows);
|
||
} catch (err) {
|
||
console.error('Erro ao buscar modelos:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Criar novo modelo
|
||
app.post('/api/modelos', requireAuth, async (req, res) => {
|
||
const { nome, finalidade, periodicidade, estrutura } = req.body;
|
||
if (!nome || !estrutura) return res.status(400).json({ error: 'Nome e estrutura são obrigatórios' });
|
||
try {
|
||
const dbRes = await dbPool.query(
|
||
"INSERT INTO escola.modelos (nome, finalidade, periodicidade, estrutura) VALUES ($1, $2, $3, $4) RETURNING *;",
|
||
[nome, finalidade, periodicidade, estrutura]
|
||
);
|
||
res.status(201).json(dbRes.rows[0]);
|
||
} catch (err) {
|
||
console.error('Erro ao criar modelo:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Atualizar modelo existente
|
||
app.put('/api/modelos/:id', requireAuth, async (req, res) => {
|
||
const { id } = req.params;
|
||
const { nome, finalidade, periodicidade, estrutura } = req.body;
|
||
if (!nome || !estrutura) return res.status(400).json({ error: 'Nome e estrutura são obrigatórios' });
|
||
try {
|
||
const dbRes = await dbPool.query(
|
||
"UPDATE escola.modelos SET nome = $1, finalidade = $2, periodicidade = $3, estrutura = $4 WHERE id = $5 RETURNING *;",
|
||
[nome, finalidade, periodicidade, estrutura, id]
|
||
);
|
||
if (dbRes.rows.length === 0) return res.status(404).json({ error: 'Modelo não encontrado' });
|
||
res.json(dbRes.rows[0]);
|
||
} catch (err) {
|
||
console.error('Erro ao atualizar modelo:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Excluir modelo
|
||
app.delete('/api/modelos/:id', requireAuth, async (req, res) => {
|
||
const { id } = req.params;
|
||
try {
|
||
const dbRes = await dbPool.query("DELETE FROM escola.modelos WHERE id = $1 RETURNING *;", [id]);
|
||
if (dbRes.rows.length === 0) return res.status(404).json({ error: 'Modelo não encontrado' });
|
||
res.json({ success: true, message: 'Modelo excluído com sucesso' });
|
||
} catch (err) {
|
||
console.error('Erro ao excluir modelo:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Obter lista única de crianças já observadas
|
||
app.get('/api/observacoes/criancas', requireAuth, async (req, res) => {
|
||
const { ano, turma } = req.query;
|
||
try {
|
||
let sql = `
|
||
SELECT DISTINCT jsonb_array_elements_text(criancas) as nome
|
||
FROM escola.observacoes
|
||
WHERE jsonb_typeof(criancas) = 'array'
|
||
`;
|
||
const params = [];
|
||
let pCount = 1;
|
||
|
||
if (ano) {
|
||
sql += ` AND substring(date from 1 for 4) = $${pCount}`;
|
||
params.push(ano);
|
||
pCount++;
|
||
}
|
||
|
||
if (turma) {
|
||
sql += ` AND turma = $${pCount}`;
|
||
params.push(turma);
|
||
pCount++;
|
||
}
|
||
|
||
sql += ` ORDER BY nome;`;
|
||
const dbRes = await dbPool.query(sql, params);
|
||
res.json(dbRes.rows.map(r => r.nome));
|
||
} catch (err) {
|
||
console.error('Erro ao buscar crianças únicas:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Obter metadados únicos para filtros (anos letivos e turmas)
|
||
app.get('/api/observacoes/metadata', requireAuth, async (req, res) => {
|
||
try {
|
||
const yearsRes = await dbPool.query(`
|
||
SELECT DISTINCT substring(date from 1 for 4) as ano
|
||
FROM escola.observacoes
|
||
WHERE date IS NOT NULL AND date != ''
|
||
ORDER BY ano DESC;
|
||
`);
|
||
const turmasRes = await dbPool.query(`
|
||
SELECT DISTINCT turma
|
||
FROM escola.observacoes
|
||
WHERE turma IS NOT NULL AND turma != ''
|
||
ORDER BY turma;
|
||
`);
|
||
res.json({
|
||
anos: yearsRes.rows.map(r => r.ano).filter(Boolean),
|
||
turmas: turmasRes.rows.map(r => r.turma).filter(Boolean)
|
||
});
|
||
} catch (err) {
|
||
console.error('Erro ao buscar metadados de observações:', err);
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Contar observações baseadas em filtros
|
||
app.get('/api/observacoes/contar', requireAuth, async (req, res) => {
|
||
const { crianca, dataInicio, dataFim, tags, ano, turma } = req.query;
|
||
try {
|
||
let sql = `SELECT COUNT(*) FROM escola.observacoes WHERE 1=1`;
|
||
const params = [];
|
||
let pCount = 1;
|
||
|
||
if (crianca) {
|
||
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 (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) {
|
||
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 (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 systemPrompt = `Você é a ${agentName}, uma assistente pedagógica e especialista em educação infantil.
|
||
Seu objetivo é analisar o histórico de observações de uma criança e redigir um relatório pedagógico consolidado e profissional.
|
||
|
||
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 OpenRouter
|
||
try {
|
||
console.log('[Compilar Relatório] Chamando 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: 2500
|
||
})
|
||
});
|
||
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: 2500 }
|
||
})
|
||
});
|
||
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: 2500
|
||
})
|
||
});
|
||
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 || '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 compileRes = 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: compilePrompt }],
|
||
temperature: 0.4,
|
||
max_tokens: 800
|
||
})
|
||
});
|
||
|
||
const compileData = await compileRes.json();
|
||
const resumoText = compileData.choices?.[0]?.message?.content || '';
|
||
|
||
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' });
|
||
}
|
||
return res.json({ success: true, url: `assets/${fileName}` });
|
||
});
|
||
});
|
||
|
||
// Rota de login (página)
|
||
app.get('/login', (req, res) => {
|
||
const session = req.signedCookies.camila_session || req.cookies.camila_session;
|
||
if (session === 'authenticated') {
|
||
res.redirect('/');
|
||
} else {
|
||
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
||
}
|
||
});
|
||
|
||
// API de status do modelo
|
||
app.get('/api/status', requireAuth, (req, res) => {
|
||
res.json({
|
||
model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano',
|
||
provider: 'openrouter',
|
||
minimax_available: !!process.env.MINIMAX_API_KEY,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
});
|
||
|
||
// API para Login
|
||
app.post('/api/login', (req, res) => {
|
||
const { password } = req.body;
|
||
if (password === ACCESS_PASSWORD) {
|
||
// Define cookie de sessão assinado que dura 30 dias
|
||
res.cookie('camila_session', 'authenticated', {
|
||
signed: true,
|
||
httpOnly: true,
|
||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dias
|
||
sameSite: 'lax',
|
||
secure: process.env.NODE_ENV === 'production'
|
||
});
|
||
res.json({ success: true });
|
||
} else {
|
||
res.status(401).json({ error: 'Senha incorreta' });
|
||
}
|
||
});
|
||
|
||
// API para Logout
|
||
app.post('/api/logout', (req, res) => {
|
||
res.clearCookie('camila_session');
|
||
res.json({ success: true });
|
||
});
|
||
|
||
// Proteger arquivos estáticos do diretório public exceto o login
|
||
app.use((req, res, next) => {
|
||
if (req.path === '/login' || req.path === '/login.html' || req.path === '/login.js' || req.path === '/style.css' || req.path.startsWith('/assets/')) {
|
||
return next();
|
||
}
|
||
requireAuth(req, res, next);
|
||
});
|
||
|
||
// Servir arquivos estáticos do diretório public
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
// System prompt da Kemily (injetado em todas as conversas)
|
||
const KEMILY_SYSTEM_PROMPT = {
|
||
role: "system",
|
||
content: `Você é a Kemily, assistente virtual e extensionista digital da Profª Camila Martella Gasparini Reifonas.
|
||
|
||
═══════════════════════════════════════════════════
|
||
IDENTIDADE — Quem é a Camila (para que você possa falar dela com precisão)
|
||
═══════════════════════════════════════════════════
|
||
|
||
INFORMAÇÕES PESSOAIS:
|
||
- Nome completo: Camila Martella Gasparini Reifonas
|
||
- Profissão: Professora da Educação Infantil na rede municipal de São Bernardo do Campo, SP
|
||
|
||
SITUAÇÃO FUNCIONAL / CONTEXTO TEMPORAL:
|
||
- **Ano de 2024 (ano passado)**: Professora efetiva de turma fixa, atuava no Infantil III (turma de 4-5 anos) como professora titular da turma.
|
||
- **Ano de 2025 (este ano)**: Professora substituta (não efetiva), não está em turma fixa. Atua no projeto Mind Lab como professora substituta, ajudando em atividades e projetos da coordenação.
|
||
- Etapa de atuação atual: projeto Mind Lab / substituição na Educação Infantil
|
||
- Local de trabalho: EMEB Mariana Neves Interliche — Sala 7, Turma B (contexto 2024)
|
||
|
||
ESCOLA — EMEB MARIANA NEVES INTERLICHE:
|
||
- Tipo: Escola Municipal de Educação Infantil (e Ensino Fundamental)
|
||
- Diretora: Maria (conforme documentos da Camila)
|
||
- Coordenadoras: Sirlene e Fernanda (coordenação pedagógica)
|
||
- Localização: São Bernardo do Campo, SP — região do Grande ABC
|
||
|
||
REDE MUNICIPAL DE SÃO BERNARDO DO CAMPO:
|
||
- Secretaria Municipal de Educação (SME)
|
||
- Subsidiado por: BNCC, Proposta Curricular de SBC, documento orientador da rede
|
||
- Formadora de referência (2024): Formadora Márcia (formações sobre leitura e escrita)
|
||
- Coordenadora de área/volante: Professora Sirlene
|
||
|
||
**ATENÇÃO - COORDENAÇÃO 2025:**
|
||
- Márcia era a formadora de referência em 2024. Para 2025, a coordenação pode ter mudado — não temos informação confirmada sobre quem ocupa esse papel neste ano. Se não souber, diga "não tenho essa informação".
|
||
|
||
SUPERIORAS/DOCENTES DE REFERÊNCIA (2024):
|
||
- Gestão da escola: Maria (diretora)
|
||
- Coordenação pedagógica: Sirlene e Fernanda (coordenadoras)
|
||
- Formadora da rede: Márcia (2024)
|
||
- Professora referenciada para substituição: Professora com quem Camila substitui
|
||
|
||
QUANDO A CAMILA PERGUNTAR "QUEM É A CAMILA" OU "FALE SOBRE MIM":
|
||
Responda com orgulho e detalhes, usando informações dos documentos reais. Exemplo:
|
||
"Você é a Profª Camila Martella Gasparini Reifonas, professora substituta da rede municipal de São Bernardo do Campo. Atua no Infantil III, turma B, Sala 7 da EMEB Mariana Neves Interliche. Você é reconhecida pelo seu compromisso com o protagonismo infantil, a escuta ativa e o desenvolvimento integral das crianças. Escreve em estilo formal-pedagógico com calor humano, utilizando expressões como 'colocando as crianças como protagonistas' e 'nos mais diversos contextos'. Trabalha com a metodologia Mind Lab, desenvolvimento fonológico e seguimento da BNCC, sempre priorizando a intencionalidade educativa em suas práticas."
|
||
|
||
SE NÃO SOUBER ALGUMA INFORMAÇÃO: Diga "Não tenho essa informação nos meus documentos, mas posso te ajudar a encontrar isso" — nunca invente.
|
||
|
||
═══════════════════════════════════════════════════
|
||
PERFIL DA PROFESSORA
|
||
═══════════════════════════════════════════════════
|
||
|
||
- Rede municipal de São Bernardo do Campo, SP
|
||
- EMEB Mariana Neves Interliche
|
||
- Turma: Infantil III – Turma B, Sala 7
|
||
- Faixa etária: crianças de 4-5 anos
|
||
- Atuação em substituição (professora substituta)
|
||
- Metodologias: Mind Lab (jogos certificados INMETRO), desenvolvimento fonológico, BNCC
|
||
- Estilo de escrita: formal-pedagógico com calor humano — frases longas, encadeadas, com posicionamentos pessoais ("ao meu ver", "creio que")
|
||
|
||
SUA MISSÃO:
|
||
Ajudar a Camila na criação de relatórios HTPC, planejamentos quinzenais/mensais, resumos de livros infantis, registros de formação, estudos de caso e documentos pedagógicos diversos.
|
||
|
||
═══════════════════════════════════════════════════
|
||
APRENDIZADO CONTÍNUO — Aprendendo com a Camila
|
||
═══════════════════════════════════════════════════
|
||
|
||
Você pode e DEVE aprender novas informações com a Camila durante nossas conversas.
|
||
|
||
QUANDO APRENDER algo novo (fato, nome, projeto, detalhe):
|
||
- Quando a Camila te ensinar algo, diga: "Anotado! Vou lembrar disso." ou similar
|
||
- Em seguida, chame a função para salvar esse conhecimento: o sistema gravará automaticamente
|
||
|
||
QUANDO NÃO SOUBER algo ou tiver DÚVIDA:
|
||
- Pergunte diretamente à Camila: "Camila, pode me ajudar a entender [assunto]? Quem é [pessoa]? O que é [projeto]?"
|
||
- Nunca finja que sabe — seja transparente e peça ajuda
|
||
- Após a resposta da Camila, o sistema salvará essa informação para consultas futuras
|
||
|
||
FONTES DE CONHECIMENTO VÁLIDAS:
|
||
- Conversas com a Camila (ela te ensina diretamente)
|
||
- Documentos que ela compartilhar (enviados por ela ou mencionados)
|
||
- Informações da pasta /Agente (dossiers, estilos, projetos)
|
||
- NÃO invente informações — se não aprendeu, pergunte
|
||
|
||
MANEIRA DE PEDIR ESCLARECIMENTO à Camila:
|
||
"Percebi que não tenho essa informação nos meus documentos. Você poderia me ensinar sobre [assunto]? Assim consigo te ajudar melhor da próxima vez."
|
||
|
||
QUANDO APRENDER algo novo da Camila:
|
||
Quando ela te ensinar um fato, nome, projeto ou detalhe novo, inclua NO FINAL da sua resposta a tag:
|
||
[KNOWLEDGE: o que você aprendeu]
|
||
|
||
Exemplo: se a Camila disser "a coordenação deste ano está sob responsabilidade da profª Ana", você responde normal e inclui:
|
||
[KNOWLEDGE: Coordenação 2025 está sob responsabilidade da profª Ana]
|
||
|
||
═══════════════════════════════════════════════════
|
||
ESTILO DE ESCRITA DA CAMILA — REGRAS RIGOROSAS
|
||
═══════════════════════════════════════════════════
|
||
|
||
A Camila escreve em estilo FORMAL-PEDAGÓGICO COM CALOR HUMANO. Você DEVE seguir todas estas regras ao produzir textos para ela:
|
||
|
||
1. FRASES LONGAS E ENCADEADAS:
|
||
✅ "O professor media esse conhecimento prévio, estando atento e propondo experiências significativas."
|
||
❌ Não use frases curtas e secas. Nunca fragmente o pensamento.
|
||
|
||
2. VOCABULÁRIO TÉCNICO-PEDAGÓGICO (uso natural, não forçado):
|
||
- protagonismo, intencionalidade educativa, desenvolvimento integral
|
||
- escuta ativa, vivências, saberes prévios, mediação, autorregulação
|
||
- potencialidades, avanços, coerência, propostas
|
||
- campos de experiência, BNCC, etapa do ensino
|
||
|
||
3. EXPRESSÕES CARACTERÍSTICAS (são MARCA, use com naturalidade):
|
||
- "colocando as crianças como protagonistas"
|
||
- "faz parte do mundo letrado"
|
||
- "nos mais diversos contextos/formatos/portadores"
|
||
- "propiciando assim"
|
||
- "a meu entender" / "ao meu ver" / "creio que"
|
||
- "dessa forma" / "nesse sentido" / "para além de"
|
||
- "a partir de" / "por meio de" / "acerca de"
|
||
- "partilhou" / "socializou" (em vez de "compartilhou")
|
||
- "aguçando interesses e curiosidade"
|
||
- "compartilhada" / "coletiva"
|
||
|
||
4. ESTRUTURA DE PARÁGRAFO:
|
||
Afirmação inicial → Desenvolvimento com exemplos/conexões → Transição para próximo ponto
|
||
|
||
5. CENTRALIDADE NA CRIANÇA:
|
||
Sempre volte ao estudante como protagonista do processo ensino-aprendizagem.
|
||
|
||
6. POSICIONAMENTO PESSOAL:
|
||
Use "Considero que...", "Entendo que...", "Creio que...", "Ao meu ver..." para se posicionar.
|
||
|
||
7. O QUE EVITAR:
|
||
- ❌ "de acordo com" → use "conforme", "segundo", "a partir de"
|
||
- ❌ "no dia a dia" → use "cotidianamente", "no cotidiano"
|
||
- ❌ "em relação a" → use "sobre", "acerca de", "a respeito de"
|
||
- ❌ fragmentação em frases curtas
|
||
- ❌ gírias ou linguagem informal
|
||
- ❌ linguagem burocrática vazia
|
||
|
||
═══════════════════════════════════════════════════
|
||
EXEMPLOS REAIS DA ESCRITA DA CAMILA
|
||
═══════════════════════════════════════════════════
|
||
|
||
EXEMPLO 1 — Relatório HTPC (avaliação anual):
|
||
"O professor como observador, faz escuta ativa da criança, para compreender o que mais chama atenção da turma, leva para a roda de conversa, aponta as curiosidades e assim consegue planejar e executar as práticas pedagógicas, colocando as crianças como protagonistas do processo ensino-aprendizagem. O planejamento se baseia principalmente nos interesses das crianças, trazendo situações que desafiam e aguçam seus interesses e curiosidade."
|
||
|
||
EXEMPLO 2 — Carta de Intenções:
|
||
"Inicio esta carta de intenções para o ano de 2026 com esta citação atribuída à escritora Lya Luft, pois reconheço claramente que a organização e o atendimento estabelecidos na Educação Infantil atuam como fundamentos desta etapa basilar da vida humana, merecendo especial atenção, dos aspectos simples aos mais complexos, e da qualidade deste trabalho dependem as fases posteriores de crescimento, aprendizagem, amadurecimento e desenvolvimento integral de cada criança."
|
||
|
||
EXEMPLO 3 — Planejamento quinzenal:
|
||
"Durante a visita ao mercadinho, pretendo propor que as crianças criem uma salad nutritiva, a partir dos vegetais que encontrarem nos setores de hortifruti, latarias e congelados, verificando o preço desses alimentos, e posteriormente faremos uma listagem verificando quais tipos de vitaminas e sais minerais encontraremos nessa salada e sua funcionalidade para o organismo."
|
||
|
||
EXEMPLO 4 — Registro de formação:
|
||
"Fomos divididas nos seguintes grupos: Grupo 1 — Viviane, Camila, Patrícia e Vanessa: jogos estruturados, conversamos sobre apresentar jogos de chão para ir trabalhando regras gerais com as crianças, para depois utilizar material ATTO e Mind Lab com regras mais estabelecidas, cuidando das peças, utilizar Lego, repertoriar as crianças, deixar explorar o material utilizando peças maiores, deixar as crianças criando por um momento livres e depois contextualizando as montagens com alguma história."
|
||
|
||
═══════════════════════════════════════════════════
|
||
CONHECIMENTO ESPECÍFICO DA CAMILA
|
||
═══════════════════════════════════════════════════
|
||
|
||
PROJETOS E VIVÊNCIAS DA ESCOLA:
|
||
- Entradas coletivas (com presentations de poesias)
|
||
- Atividades intersalas (jogos de tabuleiro, brincadeiras coletivas, tema)
|
||
- Piquenique literário (contração de histórias pelas crianças do Infantil IV e V)
|
||
- Saraus de poesia
|
||
- Painéis coletivos e murais
|
||
- Dia da família na escola
|
||
- Sábado letivo (apresentações de turmas para famílias)
|
||
- Estudo do meio (ex: Borboletário de Diadema)
|
||
- Mercadinho (sala 4 — trabalho com números, preços, lista)
|
||
- Projeto de leitura (empréstimo de livros da BEI)
|
||
- Show de acrobacias, baile à fantasia, cabelo maluco
|
||
- Teatro de bonecos, cineminha com pipoca
|
||
- Gincana, cataventos
|
||
|
||
MOMENTOS DO HTPC:
|
||
- "Nutrição" (momento inicial com livro/vídeo compartilhado)
|
||
- Frase do dia
|
||
- Momento da brincadeira
|
||
- Pauta da reunião
|
||
- Grupos de discussão
|
||
- Encerramento com registro
|
||
|
||
LIVROS INFANTIS CONHECIDOS:
|
||
- "Shhh! Nós temos um plano" (Chris Haughton)
|
||
- "A Galinha Ruiva" (Byron Barton)
|
||
- "Marcelo, Marmelo, Martelo" (Ruth Rocha)
|
||
- "O Ponto" (Peter H. Reynolds)
|
||
- "O Fabricante de Lápis"
|
||
- "A Primeira Infância" (vídeo)
|
||
- "Como os doces brasileiros ficaram tão doces" (vídeo)
|
||
|
||
FORMADORAS/MENTORAS:
|
||
- Formadora Márcia (formações sobre leitura e escrita)
|
||
- Sirlene (coordenadora)
|
||
- Fernanda (coordenadora)
|
||
- Maria (gestora)
|
||
|
||
METODOLOGIA:
|
||
- Mind Lab (jogos certificados INMETRO: Tik Tak Toy, Hora do Rush, Caixa Mágica, Resta-Um, Quatro-em-linha)
|
||
- Material ATTO (durável, para jogos de chão)
|
||
- Brincadeiras musicais e cantigas de roda
|
||
- Contação de histórias
|
||
- Desenvolvimento fonológico (rimas, ritmo)
|
||
- Desenvolvimento sintático (estruturação de frases)
|
||
- Desenvolvimento semântico (significados e vocabulário)
|
||
- Desenvolvimento pragmático (uso social da linguagem)
|
||
- Professor como escriba
|
||
|
||
BNCC/CAMPOS DE EXPERIÊNCIA:
|
||
- O eu, o outro e o nós
|
||
- Corpo, gestos e movimentos
|
||
- Traços, sons, cores e formas
|
||
- Escuta, fala, pensamento e imaginação
|
||
- Espaços, tempos, quantidades, relações e transformações
|
||
|
||
ESCRITA DA CRIANÇA:
|
||
- Hipótese de escrita silábico-alfabética
|
||
- Hipótese de escrita alfabética
|
||
|
||
═══════════════════════════════════════════════════
|
||
SISTEMA DE OBSERVAÇÕES POR VOZ — Como acessar registros
|
||
═══════════════════════════════════════════════════
|
||
|
||
A Camila possui um sistema de OBSERVAÇÕES POR VOZ onde grava registros sobre crianças durante o dia. Esses registros são salvos e ficam disponíveis para consulta.
|
||
|
||
QUANDO A CAMILA PEDIR "observações da [nome da criança]", "resumo da [criança]", "ler observações da [nome]" ou similar:
|
||
→ Use a API: GET /api/observacoes/resumo?crianca=[nome]&mes=YYYY-MM
|
||
→ O sistema retorna um RESUMO PEDAGÓGICO compilado de todas as observações daquela criança no período
|
||
→ Use esse resumo para ajudar a elaborar HTPCs, relatórios, planejamentos
|
||
|
||
EXEMPLOS DE COMANDO:
|
||
- "mostre as observações da Maria deste mês"
|
||
- "resumo pedagógico da Ana"
|
||
- "ler observações do João"
|
||
- "compilar observações da Sofia em maio"
|
||
|
||
AO RECEBER O RESUMO:
|
||
- Analise o conteúdo returned
|
||
- Use para fundamentar documentos que a Camila pedir
|
||
- Se não houver observações, diga: "Não encontrei observações registradas para [criança] neste período. Posso ajudar de outra forma?"
|
||
- Integre o resumo de forma natural no documento que estiver criando
|
||
|
||
LIMITES:
|
||
- Máximo 15 observações por consulta
|
||
- Resumos são compactados para caber no contexto
|
||
- Se a Camila pedir mais detalhes, pode mostrar a lista de observações individuais
|
||
|
||
═══════════════════════════════════════════════════
|
||
REGRAS DE ATUAÇÃO
|
||
═══════════════════════════════════════════════════
|
||
|
||
1. Quando a Camila pedir um documento (relatório, planejamento, etc):
|
||
- Seguir ESTRITAMENTE o estilo de escrita dela
|
||
- Usar frases longas e encadeadas
|
||
- Incluir vocabulário técnico-pedagógico natural
|
||
- Posicionar-se pessoalmente quando apropriado
|
||
- Manter centralidade na criança
|
||
|
||
2. Quando pedir resumo de livro infantil:
|
||
- Identificar temas, personagens, lições
|
||
- Sugerir atividades práticas de sala
|
||
- Conectar com BNCC e campos de experiência
|
||
|
||
3. Quando pedir pesquisa pedagógica:
|
||
- Fundamentar com referências reais
|
||
- Dar exemplos práticos conectando com a realidade da turma
|
||
|
||
4. Para relatórios HTPC, usar estrutura:
|
||
- Planejamento e execução das práticas pedagógicas
|
||
- Coerência entre propostas e desenvolvimento
|
||
- Diversidade de experiências e intencionalidade educativa
|
||
- Avaliação e acompanhamento das aprendizagens
|
||
|
||
5. Nunca inventar nomes de crianças, atividades ou datas. Use apenas o que conhece dos documentos.
|
||
|
||
6. Referências: usar "conforme a BNCC", "segundo a Proposta Curricular de SBC", "a partir do documento orientador".
|
||
|
||
7. Se perguntarem sobre a Camila (quem ela é, onde trabalha, quem são suas superiores, etc): use as informações da seção IDENTIDADE. Se não souber algo, diga claramente que não tem a informação — nunca invente.
|
||
|
||
Responda 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.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 detectResponse;
|
||
let usingGroqForDetection = false;
|
||
|
||
try {
|
||
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
|
||
})
|
||
});
|
||
} catch (err) {
|
||
console.error('[Smart Router] Falha de conexão com OpenRouter para detecção, tentando Groq...', err.message);
|
||
}
|
||
|
||
// Se OpenRouter falhou ou retornou erro (ex: status != 200), tenta o Groq como fallback
|
||
if ((!detectResponse || !detectResponse.ok) && process.env.GROQ_API_KEY) {
|
||
console.log('[Smart Router] Usando Groq (llama-3.1-8b-instant) para classificar intenção...');
|
||
try {
|
||
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
|
||
})
|
||
});
|
||
usingGroqForDetection = detectResponse.ok;
|
||
} catch (groqErr) {
|
||
console.error('[Smart Router] Falha de detecção na API da Groq:', groqErr.message);
|
||
}
|
||
}
|
||
|
||
if (detectResponse && detectResponse.ok) {
|
||
const detectData = await detectResponse.json();
|
||
const detectedText = detectData.choices?.[0]?.message?.content?.toUpperCase() || 'TEXT';
|
||
console.log(`[Smart Router] Resposta crua do detector: "${detectedText}"`);
|
||
|
||
if (detectedText.includes('IMAGE')) {
|
||
intent = 'IMAGE';
|
||
} else if (detectedText.includes('AUDIO') || detectedText.includes('MUSIC')) {
|
||
intent = 'AUDIO';
|
||
} else if (detectedText.includes('VIDEO')) {
|
||
intent = 'VIDEO';
|
||
} else {
|
||
intent = 'TEXT';
|
||
}
|
||
console.log(`[Smart Router] Intenção detectada para o prompt "${promptText.substring(0, 40)}...": ${intent}`);
|
||
} else {
|
||
const errText = await detectResponse.text();
|
||
console.error('[Smart Router] Chamada de detecção retornou erro:', errText);
|
||
}
|
||
} catch (err) {
|
||
console.error('Erro no classificador de intenções (usando fallback TEXT):', err);
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// FLUXO DE IMAGEM (MiniMax 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 OpenRouter...' })}\n\n`);
|
||
|
||
try {
|
||
// Prompt rewriting: pedir à IA para enriquecer o prompt em inglês
|
||
let enrichedPrompt = promptText;
|
||
try {
|
||
const rewriteResp = 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: "Reescreva o pedido de imagem do usuário em inglês como um prompt altamente detalhado para geradores de imagem (como Midjourney/Stable Diffusion). Adicione estilo de ilustração infantil fofa, vibrante, adequada para educação infantil, traço limpo, cores harmoniosas, sem textos/letras na imagem. Retorne apenas a tradução/prompt em inglês." },
|
||
{ role: 'user', content: promptText }
|
||
],
|
||
max_tokens: 150,
|
||
temperature: 0.7
|
||
})
|
||
});
|
||
if (rewriteResp.ok) {
|
||
const rewriteData = await rewriteResp.json();
|
||
enrichedPrompt = rewriteData.choices?.[0]?.message?.content?.trim() || promptText;
|
||
console.log('[Smart Router] Prompt reescrito para imagem:', enrichedPrompt);
|
||
}
|
||
} catch (err) {
|
||
console.error('Erro ao reescrever prompt de imagem:', err);
|
||
}
|
||
|
||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Gerando ilustração pedagógica com o OpenRouter...' })}\n\n`);
|
||
|
||
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: 'x-ai/grok-imagine-image-quality',
|
||
messages: [
|
||
{ role: 'user', content: enrichedPrompt }
|
||
],
|
||
modalities: ['image']
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errText = await response.text();
|
||
throw new Error(`OpenRouter respondeu com erro ao gerar imagem: ${errText}`);
|
||
}
|
||
|
||
const imgData = await response.json();
|
||
const imageObj = imgData.choices?.[0]?.message?.images?.[0];
|
||
let imageUrl = imageObj?.image_url?.url;
|
||
|
||
if (!imageUrl) {
|
||
throw new Error('Nenhuma imagem foi retornada pelo OpenRouter');
|
||
}
|
||
|
||
// Se for base64, salvar localmente no servidor
|
||
if (imageUrl.startsWith('data:')) {
|
||
const fs = require('fs');
|
||
const mediaDir = path.join(__dirname, 'public', 'generated-media');
|
||
if (!fs.existsSync(mediaDir)) {
|
||
fs.mkdirSync(mediaDir, { recursive: true });
|
||
}
|
||
|
||
const matches = imageUrl.match(/^data:image\/([A-Za-z+-]+);base64,(.+)$/);
|
||
if (matches && matches.length === 3) {
|
||
const ext = matches[1];
|
||
const base64Data = matches[2];
|
||
const fileName = `imagem_${Date.now()}.${ext === 'jpeg' ? 'jpg' : ext}`;
|
||
const filePath = path.join(mediaDir, fileName);
|
||
fs.writeFileSync(filePath, Buffer.from(base64Data, 'base64'));
|
||
imageUrl = `/generated-media/${fileName}`;
|
||
console.log(`[Smart Router] Imagem salva localmente em: ${imageUrl}`);
|
||
}
|
||
}
|
||
|
||
res.write(`data: ${JSON.stringify({ type: 'image', url: imageUrl, 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 (Mensagem Pedagógica Amigável - MiniMax Desativado)
|
||
// ==========================================================================
|
||
// ==========================================================================
|
||
// FLUXO DE ÁUDIO/MÚSICA (google/lyria-3-clip-preview)
|
||
// ==========================================================================
|
||
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 Lyria AI...' })}\n\n`);
|
||
|
||
try {
|
||
if (!process.env.OPENROUTER_API_KEY) {
|
||
throw new Error('OPENROUTER_API_KEY não configurada no servidor.');
|
||
}
|
||
|
||
const response = 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: 'google/lyria-3-clip-preview',
|
||
messages: [
|
||
{ role: 'user', content: promptText }
|
||
],
|
||
modalities: ['audio'],
|
||
stream: true
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
throw new Error(`Erro na API do OpenRouter (Lyria): ${errorText}`);
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder('utf-8');
|
||
let buffer = '';
|
||
let audioBase64Chunks = [];
|
||
let textContent = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
|
||
for (const line of lines) {
|
||
const cleanLine = line.trim();
|
||
if (cleanLine.startsWith('data: ')) {
|
||
const dataContent = cleanLine.slice(6);
|
||
if (dataContent === '[DONE]') continue;
|
||
try {
|
||
const parsed = JSON.parse(dataContent);
|
||
const delta = parsed.choices?.[0]?.delta;
|
||
if (delta) {
|
||
if (delta.content) {
|
||
textContent += delta.content;
|
||
res.write(`data: ${JSON.stringify({ content: delta.content })}\n\n`);
|
||
}
|
||
if (delta.audio?.data) {
|
||
audioBase64Chunks.push(delta.audio.data);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Ignorar
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Processar restante do 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 delta = parsed.choices?.[0]?.delta;
|
||
if (delta) {
|
||
if (delta.content) {
|
||
textContent += delta.content;
|
||
res.write(`data: ${JSON.stringify({ content: delta.content })}\n\n`);
|
||
}
|
||
if (delta.audio?.data) {
|
||
audioBase64Chunks.push(delta.audio.data);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Ignorar
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (audioBase64Chunks.length > 0) {
|
||
const fullAudioBase64 = audioBase64Chunks.join('');
|
||
|
||
// Salvar em arquivo no servidor
|
||
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(fullAudioBase64, 'base64'));
|
||
|
||
const audioUrl = `/generated-media/${fileName}`;
|
||
console.log(`[Smart Router] Música salva localmente em: ${audioUrl}`);
|
||
// Envia a mídia estruturada para o frontend
|
||
res.write(`data: ${JSON.stringify({ type: 'audio', url: audioUrl, originalPrompt: promptText })}\n\n`);
|
||
} else {
|
||
// Fallback se não retornou áudio (apenas texto)
|
||
if (textContent) {
|
||
res.write(`data: ${JSON.stringify({ content: '\n\n*(Nota: O áudio não pôde ser gerado por limitações do modelo, mas a letra acima foi composta com carinho!)*' })}\n\n`);
|
||
} else {
|
||
throw new Error('Nenhum dado de áudio ou texto foi retornado pelo modelo Lyria.');
|
||
}
|
||
}
|
||
|
||
res.write('data: [DONE]\n\n');
|
||
return res.end();
|
||
|
||
} catch (error) {
|
||
console.error('Erro na resposta de áudio com Lyria:', error);
|
||
res.write(`data: ${JSON.stringify({ content: `Desculpe, ocorreu um erro ao gerar a música: ${error.message}. No entanto, se você quiser, eu posso compor uma letra pedagógica sobre o assunto para você!` })}\n\n`);
|
||
res.write('data: [DONE]\n\n');
|
||
return res.end();
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// FLUXO DE VÍDEO (google/veo-3.1-lite via OpenRouter)
|
||
// ==========================================================================
|
||
if (intent === 'VIDEO') {
|
||
res.setHeader('Content-Type', 'text/event-stream');
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
res.setHeader('Connection', 'keep-alive');
|
||
|
||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Iniciando geração de vídeo com o modelo google/veo-3.1-lite...' })}\n\n`);
|
||
|
||
try {
|
||
// 1. Criar o job de geração de vídeo no OpenRouter
|
||
const createResponse = await fetch('https://openrouter.ai/api/v1/videos', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'google/veo-3.1-lite',
|
||
prompt: promptText
|
||
})
|
||
});
|
||
|
||
if (!createResponse.ok) {
|
||
const errorText = await createResponse.ok ? '' : await createResponse.text();
|
||
throw new Error(`Falha ao iniciar geração de vídeo no OpenRouter: ${errorText || createResponse.statusText}`);
|
||
}
|
||
|
||
const jobData = await createResponse.json();
|
||
const jobId = jobData.id;
|
||
const pollingUrl = jobData.polling_url || `https://openrouter.ai/api/v1/videos/${jobId}`;
|
||
|
||
console.log(`[Smart Router] Job de vídeo iniciado no OpenRouter com ID: ${jobId}`);
|
||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Vídeo enfileirado no OpenRouter, aguardando processamento...' })}\n\n`);
|
||
|
||
// 2. Polling loop para monitorar a conclusão do job
|
||
let status = 'queued';
|
||
let videoUrl = null;
|
||
let attempts = 0;
|
||
const maxAttempts = 30; // 3 minutos no total (6s por tentativa)
|
||
|
||
while (attempts < maxAttempts) {
|
||
attempts++;
|
||
// Aguarda 6 segundos antes de verificar novamente
|
||
await new Promise(resolve => setTimeout(resolve, 6000));
|
||
|
||
console.log(`[Smart Router] Verificando status do job de vídeo ${jobId} (Tentativa ${attempts})...`);
|
||
|
||
const statusResponse = await fetch(pollingUrl, {
|
||
headers: {
|
||
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`
|
||
}
|
||
});
|
||
|
||
if (!statusResponse.ok) {
|
||
console.error(`[Smart Router] Falha ao consultar status do job de vídeo: ${statusResponse.statusText}`);
|
||
continue;
|
||
}
|
||
|
||
const statusData = await statusResponse.json();
|
||
status = statusData.status;
|
||
|
||
if (status === 'completed') {
|
||
videoUrl = statusData.unsigned_urls?.[0];
|
||
break;
|
||
} else if (status === 'failed') {
|
||
throw new Error('A geração do vídeo foi marcada como falha no OpenRouter.');
|
||
} else {
|
||
// in_progress / queued / processing
|
||
const progressPercent = Math.round((attempts / maxAttempts) * 100);
|
||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: `Processando vídeo no OpenRouter (${progressPercent}%)...` })}\n\n`);
|
||
}
|
||
}
|
||
|
||
if (!videoUrl) {
|
||
throw new Error('Tempo limite esgotado para a geração do vídeo no OpenRouter.');
|
||
}
|
||
|
||
res.write(`data: ${JSON.stringify({ type: 'media_status', message: 'Vídeo concluído! Baixando arquivo para o servidor...' })}\n\n`);
|
||
|
||
// 3. Baixar o arquivo MP4 e salvar localmente
|
||
const videoFetch = await fetch(videoUrl);
|
||
if (!videoFetch.ok) {
|
||
throw new Error('Falha ao efetuar download do arquivo de vídeo temporário');
|
||
}
|
||
|
||
const videoBuffer = await videoFetch.arrayBuffer();
|
||
const fs = require('fs');
|
||
const mediaDir = path.join(__dirname, 'public', 'generated-media');
|
||
if (!fs.existsSync(mediaDir)) {
|
||
fs.mkdirSync(mediaDir, { recursive: true });
|
||
}
|
||
|
||
const fileName = `video_${Date.now()}.mp4`;
|
||
const filePath = path.join(mediaDir, fileName);
|
||
fs.writeFileSync(filePath, Buffer.from(videoBuffer));
|
||
|
||
const localVideoUrl = `/generated-media/${fileName}`;
|
||
console.log(`[Smart Router] Vídeo salvo localmente em: ${localVideoUrl}`);
|
||
|
||
res.write(`data: ${JSON.stringify({ type: 'video', url: localVideoUrl, originalPrompt: promptText })}\n\n`);
|
||
res.write('data: [DONE]\n\n');
|
||
return res.end();
|
||
|
||
} catch (error) {
|
||
console.error('Erro na geração de vídeo:', error);
|
||
res.write(`data: ${JSON.stringify({ content: `Desculpe, ocorreu um erro ao gerar o vídeo: ${error.message}.` })}\n\n`);
|
||
res.write('data: [DONE]\n\n');
|
||
return res.end();
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// FLUXO PADRÃO DE TEXTO (OpenRouter com SSE streaming original, com fallback Groq)
|
||
// ==========================================================================
|
||
const agentConfig = readAgentConfig();
|
||
const agentName = agentConfig.agentName || "Kemily";
|
||
const temperaturePreset = agentConfig.temperaturePreset || 'equilibrado';
|
||
const temperature = TEMPERATURE_PRESETS[temperaturePreset]?.value || 0.6;
|
||
|
||
const knowledgeText = getKnowledgeAsText();
|
||
|
||
// 1.2 Integração Dinâmica de Observações no Chat da IA
|
||
let observationsContext = '';
|
||
try {
|
||
const idx = readObsIndex();
|
||
const allChildren = new Set();
|
||
(idx.observations || []).forEach(o => {
|
||
if (o.criancas) {
|
||
o.criancas.forEach(c => allChildren.add(c.trim()));
|
||
}
|
||
});
|
||
|
||
const mentionedChildren = [];
|
||
const promptLower = promptText.toLowerCase();
|
||
|
||
for (const child of allChildren) {
|
||
const regex = new RegExp(`\\b${child.toLowerCase()}\\b`, 'i');
|
||
if (regex.test(promptLower)) {
|
||
mentionedChildren.push(child);
|
||
}
|
||
}
|
||
|
||
if (mentionedChildren.length > 0) {
|
||
// Verificar palavras-chave relacionadas à busca de observações
|
||
const askKeywords = ['observa', 'registro', 'historico', 'histórico', 'resumo', 'relatorio', 'relatório', 'desenvolvimento', 'evolução', 'evolucao', 'comportamento', 'como foi', 'como está', 'como esta', 'pedagó'];
|
||
const isAskingAboutObs = askKeywords.some(kw => promptLower.includes(kw));
|
||
|
||
if (isAskingAboutObs) {
|
||
let compiledObservations = [];
|
||
for (const child of mentionedChildren) {
|
||
const childObs = (idx.observations || []).filter(o =>
|
||
o.criancas && o.criancas.some(c => c.toLowerCase() === child.toLowerCase())
|
||
);
|
||
|
||
if (childObs.length > 0) {
|
||
// Carrega as últimas 5 observações
|
||
const lastObs = childObs.slice(0, 5);
|
||
const obsTexts = [];
|
||
for (const o of lastObs) {
|
||
const filePath = path.join(OBSERVACOES_DIR, o.date?.slice(0, 7), o.filename);
|
||
if (fs.existsSync(filePath)) {
|
||
let content = fs.readFileSync(filePath, 'utf8');
|
||
content = content.replace(/^---[\s\S]*?---\n/, '').trim();
|
||
obsTexts.push(`- Data: ${o.date} (${o.time}):\n${content}`);
|
||
}
|
||
}
|
||
if (obsTexts.length > 0) {
|
||
compiledObservations.push(`### Observações de ${child}:\n${obsTexts.join('\n\n')}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (compiledObservations.length > 0) {
|
||
observationsContext = `\n\n════════════════════════════════════════\nOBSERVAÇÕES REGISTRADAS SOBRE AS CRIANÇAS CITADAS:\n${compiledObservations.join('\n\n')}\nUse as observações acima para responder à pergunta da Camila sobre o desenvolvimento/comportamento da criança de forma precisa e baseada em evidências.\n════════════════════════════════════════\n`;
|
||
console.log(`[Smart Router] Injetando contexto de observações para:`, mentionedChildren);
|
||
}
|
||
}
|
||
}
|
||
} catch (obsErr) {
|
||
console.error('Erro ao integrar observações no prompt da IA:', obsErr);
|
||
}
|
||
|
||
const dynamicSystemPrompt = {
|
||
role: "system",
|
||
content: KEMILY_SYSTEM_PROMPT.content
|
||
.replace(/Você é a Kemily/g, `Você é a ${agentName}`)
|
||
.replace('INFORMAÇÕES PESSOAIS:', `${knowledgeText}${observationsContext}INFORMAÇÕES PESSOAIS:`)
|
||
};
|
||
|
||
const enrichedMessages = [dynamicSystemPrompt, ...messages];
|
||
|
||
try {
|
||
let chatResponse;
|
||
let providerUsed = '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 M2.7
|
||
if ((!chatResponse || !chatResponse.ok) && process.env.MINIMAX_API_KEY) {
|
||
console.log('[Smart Router] Usando MiniMax-M2.7 como fallback de streaming de texto...');
|
||
providerUsed = 'MiniMax M2.7';
|
||
try {
|
||
chatResponse = 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: enrichedMessages,
|
||
stream: true,
|
||
temperature
|
||
})
|
||
});
|
||
} catch (minimaxErr) {
|
||
console.error('[Smart Router] Erro crítico: Falha no fallback do MiniMax M2.7 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();
|
||
});
|