Files
camila/server.js
T

2531 lines
110 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const multer = require('multer');
const 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 });
});
app.put('/api/conhecimento/:id', requireAuth, async (req, res) => {
const { content } = req.body;
if (!content) return res.status(400).json({ error: 'Conteúdo é obrigatório' });
try {
const id = parseInt(req.params.id);
await dbPool.query("UPDATE escola.conhecimento SET content = $1 WHERE id = $2;", [content, id]);
res.json({ success: true });
} catch (err) {
console.error('Erro ao atualizar conhecimento:', err);
res.status(500).json({ error: err.message });
}
});
// Analisar mensagem final e extrair conhecimento aprendido (chamado pelo frontend)
app.post('/api/conhecimento/extrair', requireAuth, async (req, res) => {
const { message } = req.body;
if (!message) return res.status(400).json({ error: 'Mensagem é obrigatória' });
const knowledgeRegex = /\[KNOWLEDGE:\s*([^\]]+)\]/gi;
const facts = [];
let match;
while ((match = knowledgeRegex.exec(message)) !== null) {
const fact = match[1].trim();
if (fact && fact.length > 3) {
const added = await addKnowledgeFact(fact, 'conversa');
if (added) facts.push(fact);
}
}
res.json({ success: true, factsFound: facts.length, facts });
});
app.get('/api/temperature-presets', requireAuth, (req, res) => {
res.json(TEMPERATURE_PRESETS);
});
// ============================================================
// SISTEMA DE OBSERVAÇÃO POR VOZ
// ============================================================
const OBSERVACOES_DIR = path.join(__dirname, 'observacoes');
const OBS_INDEX_FILE = path.join(OBSERVACOES_DIR, 'index.json');
// Garante que diretório de observações existe
if (!fs.existsSync(OBSERVACOES_DIR)) {
fs.mkdirSync(OBSERVACOES_DIR, { recursive: true });
}
// Lê índice de observações (mantido apenas para fins de migração inicial)
function readObsIndex() {
if (!fs.existsSync(OBS_INDEX_FILE)) return { observations: [] };
try {
return JSON.parse(fs.readFileSync(OBS_INDEX_FILE, 'utf8'));
} catch { return { observations: [] }; }
}
function writeObsIndex(idx) {
try {
fs.writeFileSync(OBS_INDEX_FILE, JSON.stringify(idx, null, 2), 'utf8');
} catch (err) {
console.error('Erro ao escrever index local:', err);
}
}
// Inicializar banco de dados e migrar dados se estiver vazio
async function initDatabase() {
try {
// 0. Garantir que a tabela de modelos exista e tenha dados padrão
await dbPool.query(`
CREATE TABLE IF NOT EXISTS escola.modelos (
id SERIAL PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
finalidade TEXT,
periodicidade VARCHAR(100),
estrutura TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`);
const checkModelos = await dbPool.query("SELECT COUNT(*) FROM escola.modelos;");
if (parseInt(checkModelos.rows[0].count) === 0) {
console.log('[Database Init] Inserindo modelos/templates padrão de relatório...');
await dbPool.query(`
INSERT INTO escola.modelos (nome, finalidade, periodicidade, estrutura) VALUES
($1, $2, $3, $4),
($5, $6, $7, $8);
`, [
"Relatório Semanal de Desenvolvimento",
"Acompanhar a evolução cognitiva e social das crianças semanalmente",
"Semanal",
`# Relatório Semanal de Desenvolvimento Pedagógico\n\n## 1. Aspectos Cognitivos e Linguagem\n- Como a criança interagiu com os materiais e desafios propostos esta semana.\n- Expressão verbal, argumentação e participação em rodas de conversa.\n\n## 2. Aspectos Socioemocionais e Autonomia\n- Colaboração com os colegas e resolução de pequenos conflitos.\n- Nível de independência na realização das atividades diárias.\n\n## 3. Aspectos Motores e Brincar\n- Coordenação motora ampla e fina durante as brincadeiras e atividades direcionadas.`,
"Avaliação Semestral Individual",
"Avaliar o progresso do aluno combinando uma nova introdução global gerada a partir das atividades do semestre com a narrativa individual de desenvolvimento.",
"Semestral",
`O relatório final deve ser gerado sem cabeçalhos institucionais ou títulos de seções (como "I. O Eu..."), contendo apenas texto corrido e estruturado em parágrafos pedagógicos formais e acolhedores no estilo da Professora Camila. O relatório deve ser composto por duas partes principais integradas:\n\nPARTE 1: INTRODUÇÃO GERAL E CONTEXTUALIZAÇÃO COLETIVA (Texto Comum da Turma)\n(Instrução para a IA: Com base nas vivências, projetos e dinâmicas coletivas que aparecem no histórico de observações, redija um texto inédito de 4 a 5 parágrafos descrevendo o trabalho pedagógico coletivo do semestre. Não cite nomes de crianças nesta parte. Siga rigorosamente a estrutura de parágrafos abaixo):\n- Parágrafo 1 — Planejamento Pedagógico & Objetivos Gerais da Turma: Apresente os focos pedagógicos do período (desenvolvimento físico, cognitivo, afetivo/emocional) e o trabalho com a convivência harmoniosa, organização dos espaços/materiais e os combinados de rotina.\n- Parágrafo 2 — Vivências Lúdicas e Simbolismo: Descreva como as brincadeiras e interações simbólicas (faz de conta) serviram para as crianças compartilharem emoções, resolverem conflitos, desenvolverem a empatia, a linguagem e o raciocínio lógico.\n- Parágrafo 3 — Contato com a Cultura Escrita: Descreva o contato das crianças com o mundo letrado e a função social da escrita por meio do acesso a diferentes portadores e gêneros textuais (livros, cartazes, listas, receitas, canções, etc.).\n- Parágrafo 4 — Projeto Temático do Semestre: Detalhe o principal projeto de pesquisa da turma no semestre (ex: Identidade e Diversidade, plantio, etc.), descrevendo as investigações, atividades práticas realizadas e os registros/painéis construídos coletivamente.\n- Parágrafo 5 — Corporeidade, Jogos e Desportividade: Descreva o trabalho com jogos (de tabuleiro, confeccionados com recicláveis) e atividades motoras (circuitos, cantigas de roda, parlendas, corda), destacando o estímulo à atenção, cooperação, agilidade e respeito a regras de convivência.\n\n---\n\nPARTE 2: DESENVOLVIMENTO INDIVIDUAL (Texto Personalizado do Aluno)\n(Instrução para a IA: Redija na sequência um texto narrativo e contínuo, focado exclusivamente no aluno específico, baseado nas observações desse estudante. Adote como inspiração de estilo, tom e estrutura os dois exemplos reais abaixo):\n\nEXEMPLOS REAIS DE INSPIRAÇÃO (Alice e Theo):\nExemplo 1:\n"Alice apresentou muitos avanços neste período. Demonstra muita autonomia nas atividades de rotina, organizando seus pertences pessoais, bem como os brinquedos de uso coletivo após o término das vivências lúdicas. Na hora da alimentação, a aluna demonstra segurança ao servir-se e porcionar sua comida sem desperdícios. No parque, interage alegremente com os colegas, preferindo brincar com potes e pazinhas na areia, demonstrando grande companheirismo e compartilhando vivências com seus amigos, em especial com a Mirella e o Natan. Apresenta ótimo desenvolvimento motor, demonstrando equilíbrio ao pedalar os triciclos, participando de gincanas e realizando circuitos com destreza. Na roda de conversa, expressa-se com clareza e demonstra interesse pela leitura e contação de histórias, folheando livros individualmente. Demonstra facilidade em seguir regras e combinados coletivos."\n\nExemplo 2:\n"Theo apresentou muitos avanços neste período. No parque, gosta de brincar com brinquedos de rodas e na areia com seus amigos, em especial o Izack, o Gabriel e o Murilo, interagindo em brincadeiras simbólicas como o jogo do \'zumbi\'. Demonstra bom desenvolvimento motor amplo, realizando circuitos de equilíbrio e gincanas com agilidade. Nas atividades de expressão oral e corporal, participa de cantigas de roda e recitações. Theo demonstra grande interesse pela leitura e pelos livros, solicitando a contação de histórias com frequência. Constrói belas estruturas tridimensionais com blocos e lego, compartilhando brinquedos após intervenção do adulto. Apresenta boa autonomia na alimentação, porcionando sua refeição de forma consciente. Demonstra evolução na organização e no cumprimento de combinados de convivência na roda e na fila, necessitando ocasionalmente do direcionamento de um adulto."\n\nDIRETRIZES DE CONTRUÇÃO DO TEXTO DO ALUNO:\n- Início Obrigatório: Comece o primeiro parágrafo exatamente com a frase: "[Nome do Aluno] apresentou muitos avanços neste período." (Substitua pelo nome do aluno avaliado).\n- Corpo da narrativa: Integre de forma orgânica, fluida e sem subtítulos ou tópicos:\n 1. Autonomia na rotina diária (organização de materiais individuais/coletivos e porcionamento alimentar na escola sem desperdício).\n 2. Brincadeiras e interações sociais (preferências no parque/areia, identificando os melhores amigos entre aspas, ex: "a Mirella").\n 3. Desenvolvimento motor amplo e fino (pular cordas, pedalar triciclos, circuitos, gincanas) e o respeito aos combinados de organização (fila, roda).\n 4. Linguagem e oralidade (vocabulário, clareza verbal, participação em recitações/música e interesse em livros de histórias).\n 5. Criatividade nas atividades diversificadas (lego/blocos 3D, pinturas, desenhos de formas definidas).\n 6. Desafios/Pontos de atenção: Se houver, utilize sempre linguagem pedagógica atenuada (ex: "necessitando de orientação do adulto", "permanece o desafio de", "com intervenções constantes"). Nunca faça críticas diretas.\n- Fechamento Obrigatório: Finalize o relatório com o seguinte parágrafo isolado: "Espero que o/a [Nome do Aluno] continue se interessando bastante pela escola e avançando muito em suas aprendizagens no próximo ano." (Substitua pelo nome do aluno).`
]);
}
// 1. Sincronizar cache de configurações do agente ou migrar
const configRes = await dbPool.query("SELECT agent_name, kemily_avatar_url, camila_avatar_url, temperature_preset FROM escola.config WHERE key = 'current';");
if (configRes.rows.length > 0) {
agentConfigCache = {
agentName: configRes.rows[0].agent_name,
kemilyAvatarUrl: configRes.rows[0].kemily_avatar_url,
camilaAvatarUrl: configRes.rows[0].camila_avatar_url,
temperaturePreset: configRes.rows[0].temperature_preset
};
console.log('[Database Init] Configurações do agente carregadas do banco de dados:', agentConfigCache.agentName);
} else {
console.log('[Database Init] Migrando configurações do agente do arquivo local...');
let localConfig = {
agentName: "Kemily",
kemilyAvatarUrl: "assets/kemily.png",
camilaAvatarUrl: "assets/camila_prof.png",
temperaturePreset: "equilibrado"
};
try {
if (fs.existsSync(CONFIG_FILE_PATH)) {
localConfig = JSON.parse(fs.readFileSync(CONFIG_FILE_PATH, 'utf8'));
}
} catch (err) {
console.error('[Database Init] Erro ao ler agent_config.json local:', err.message);
}
agentConfigCache = localConfig;
await dbPool.query(
"INSERT INTO escola.config (key, agent_name, kemily_avatar_url, camila_avatar_url, temperature_preset) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key) DO NOTHING;",
['current', localConfig.agentName, localConfig.kemilyAvatarUrl, localConfig.camilaAvatarUrl, localConfig.temperaturePreset || 'equilibrado']
);
}
// 2. Verificar/Migrar fatos de conhecimento
const knowledgeRes = await dbPool.query("SELECT COUNT(*) FROM escola.conhecimento;");
if (parseInt(knowledgeRes.rows[0].count) === 0) {
console.log('[Database Init] Migrando conhecimento do arquivo local...');
let localKnowledge = { facts: [], lastUpdated: null };
try {
if (fs.existsSync(KNOWLEDGE_FILE_PATH)) {
localKnowledge = JSON.parse(fs.readFileSync(KNOWLEDGE_FILE_PATH, 'utf8'));
}
} catch (err) {
console.error('[Database Init] Erro ao ler conhecimento_adquirido.json local:', err.message);
}
for (const fact of localKnowledge.facts || []) {
await dbPool.query(
"INSERT INTO escola.conhecimento (content, source, added_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;",
[fact.content, fact.source, fact.addedAt]
);
}
}
// 3. Verificar/Migrar observações pedagógicas
const obsRes = await dbPool.query("SELECT COUNT(*) FROM escola.observacoes;");
if (parseInt(obsRes.rows[0].count) === 0) {
console.log('[Database Init] Migrando observações do arquivo local...');
let localIndex = { observations: [] };
try {
if (fs.existsSync(OBS_INDEX_FILE)) {
localIndex = JSON.parse(fs.readFileSync(OBS_INDEX_FILE, 'utf8'));
}
} catch (err) {
console.error('[Database Init] Erro ao ler index.json local:', err.message);
}
for (const obs of localIndex.observations || []) {
let reportContent = obs.preview;
try {
const yearMonth = obs.date.slice(0, 7);
const filePath = path.join(OBSERVACOES_DIR, yearMonth, obs.filename);
if (fs.existsSync(filePath)) {
const rawFile = fs.readFileSync(filePath, 'utf8');
const parts = rawFile.split('---\n');
if (parts.length >= 3) {
reportContent = parts.slice(2).join('---\n').trim();
} else {
reportContent = rawFile;
}
}
} catch (fileErr) {
console.error('[Database Init] Erro ao ler arquivo de relatório:', obs.filename, fileErr.message);
}
await dbPool.query(
"INSERT INTO escola.observacoes (id, filename, date, time, timestamp, criancas, turma, tags, report) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO NOTHING;",
[
obs.id,
obs.filename,
obs.date,
obs.time,
obs.timestamp,
JSON.stringify(obs.criancas),
obs.turma,
JSON.stringify(obs.tags),
reportContent
]
);
}
}
console.log('[Database Init] Inicialização e migração do banco de dados concluídas com sucesso!');
} catch (err) {
console.error('[Database Init] Erro crítico ao inicializar banco de dados:', err);
}
}
// Rota: POST /api/observacao/transcrever — recebe áudio e transcreve via Whisper
app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum áudio recebido' });
}
let ext = 'webm';
if (req.file.mimetype) {
if (req.file.mimetype.includes('mp4')) ext = 'mp4';
else if (req.file.mimetype.includes('ogg')) ext = 'ogg';
else if (req.file.mimetype.includes('wav')) ext = 'wav';
else if (req.file.mimetype.includes('mpeg')) ext = 'mp3';
}
try {
let transcribedText = '';
const fileBlob = new Blob([req.file.buffer], { type: req.file.mimetype || 'audio/webm' });
if (process.env.OPENAI_API_KEY) {
console.log('[Smart Transcription] Usando OpenAI Whisper...');
const whisperFormData = new FormData();
whisperFormData.append('file', fileBlob, `audio.${ext}`);
whisperFormData.append('model', 'whisper-1');
whisperFormData.append('language', 'pt');
whisperFormData.append('response_format', 'text');
const whisperRes = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
},
body: whisperFormData
});
if (!whisperRes.ok) {
const errText = await whisperRes.text();
console.error('OpenAI Whisper error:', errText);
throw new Error('Erro na transcrição via OpenAI');
}
transcribedText = await whisperRes.text();
} else if (process.env.GROQ_API_KEY) {
console.log('[Smart Transcription] Usando Groq Whisper...');
const whisperFormData = new FormData();
whisperFormData.append('file', fileBlob, `audio.${ext}`);
whisperFormData.append('model', 'whisper-large-v3');
whisperFormData.append('language', 'pt');
whisperFormData.append('response_format', 'text');
const whisperRes = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`
},
body: whisperFormData
});
if (!whisperRes.ok) {
const errText = await whisperRes.text();
console.error('Groq Whisper error:', errText);
throw new Error('Erro na transcrição via Groq');
}
transcribedText = await whisperRes.text();
} else {
throw new Error('Nenhuma chave de API configurada para transcrição de áudio (OPENAI_API_KEY ou GROQ_API_KEY).');
}
if (!transcribedText || transcribedText.trim().length < 2) {
return res.status(400).json({ error: 'O áudio não contém fala identificável.' });
}
res.json({ transcribedText });
} catch (err) {
console.error('Erro transcrever:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: POST /api/observacao/analisar — recebe texto transcrito e gera relatório pedagógico
app.post('/api/observacao/analisar', requireAuth, async (req, res) => {
const { transcribedText } = req.body;
if (!transcribedText || transcribedText.trim().length < 5) {
return res.status(400).json({ error: 'Texto muito curto ou inexistente para análise.' });
}
try {
const agentConfig = readAgentConfig();
const agentName = agentConfig.agentName || "Kemily";
const reportPrompt = `Você é a ${agentName}, assistente pedagógica da professora Camila Martella Gasparini Reifonas.
Ela acabou de registrar uma observação sobre seus alunos da educação infantil. Sua tarefa é ANALISAR o texto e retornar UM OBJETO JSON (sem markdown, sem código, apenas o JSON puro) com:
{
"criancas": ["lista de nomes de crianças mencionadas ou 'null' se não identificar nenhuma"],
"turma": "turma mencionada ou 'não informada'",
"tags": ["tag1", "tag2"] (social, cognitivo, motor, linguagem, emocional, colaborativo, autónomo),
"relatorio": "...markdown estruturado do relatório pedagógico..."
}
REGRAS:
- Identifique crianças pelo nome próprio mencionado na fala/texto
- Se não conseguir identificar nomes, use "criancas": null
- Tags: escolha entre social, cognitivo, motor, linguagem, emocional, colaborativo, autónomo
- Relatório em português, linguagem formal-pedagógica, seções claras em **markdown**
- Foque em evidências observáveis
- Máximo 120 palavras no relatório
OBSERVAÇÃO REGISTRADA:
"""
${transcribedText}
"""
Retorne APENAS o JSON válido:`;
let rawContent = '';
// Tentar 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 && crianca !== 'todas') {
sql += ` AND jsonb_typeof(criancas) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(criancas) AS c
WHERE LOWER(c) LIKE $${pCount}
)`;
params.push(`%${crianca.toLowerCase()}%`);
pCount++;
}
if (dataInicio) {
sql += ` AND date >= $${pCount}`;
params.push(dataInicio);
pCount++;
}
if (dataFim) {
sql += ` AND date <= $${pCount}`;
params.push(dataFim);
pCount++;
}
if (ano) {
sql += ` AND substring(date from 1 for 4) = $${pCount}`;
params.push(ano);
pCount++;
}
if (turma) {
sql += ` AND turma = $${pCount}`;
params.push(turma);
pCount++;
}
if (tags) {
const tagList = tags.split(',').map(t => t.trim().toLowerCase());
sql += ` AND jsonb_typeof(tags) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(tags) AS t
WHERE LOWER(t) = ANY($${pCount})
)`;
params.push(tagList);
pCount++;
}
const dbRes = await dbPool.query(sql, params);
res.json({ count: parseInt(dbRes.rows[0].count) });
} catch (err) {
console.error('Erro ao contar observações:', err);
res.status(500).json({ error: err.message });
}
});
// Gerar relatório consolidado com IA baseado em observações e um modelo
app.post('/api/modelos/gerar-relatorio', requireAuth, async (req, res) => {
const { crianca, dataInicio, dataFim, tags, modeloId, ano, turma } = req.body;
if (!modeloId) return res.status(400).json({ error: 'O ID do modelo é obrigatório' });
try {
// 1. Buscar o modelo/template
const templateRes = await dbPool.query("SELECT * FROM escola.modelos WHERE id = $1;", [modeloId]);
if (templateRes.rows.length === 0) return res.status(404).json({ error: 'Modelo de relatório não encontrado' });
const template = templateRes.rows[0];
// 2. Buscar as observações filtradas
let sql = `
SELECT date, time, report, criancas, tags
FROM escola.observacoes
WHERE 1=1
`;
const params = [];
let pCount = 1;
if (crianca && crianca !== 'todas') {
sql += ` AND (
(jsonb_typeof(criancas) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(criancas) AS c
WHERE LOWER(c) LIKE $${pCount}
))
OR criancas IS NULL
)`;
params.push(`%${crianca.toLowerCase()}%`);
pCount++;
}
if (dataInicio) {
sql += ` AND date >= $${pCount}`;
params.push(dataInicio);
pCount++;
}
if (dataFim) {
sql += ` AND date <= $${pCount}`;
params.push(dataFim);
pCount++;
}
if (ano) {
sql += ` AND substring(date from 1 for 4) = $${pCount}`;
params.push(ano);
pCount++;
}
if (turma) {
sql += ` AND turma = $${pCount}`;
params.push(turma);
pCount++;
}
if (tags) {
const tagList = Array.isArray(tags) ? tags : [tags];
sql += ` AND jsonb_typeof(tags) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(tags) AS t
WHERE LOWER(t) = ANY($${pCount})
)`;
params.push(tagList.map(t => t.toLowerCase()));
pCount++;
}
sql += ` ORDER BY date ASC`;
const dbRes = await dbPool.query(sql, params);
const observations = dbRes.rows;
if (observations.length === 0) {
return res.status(400).json({ error: 'Nenhuma observação encontrada para os filtros selecionados.' });
}
// 3. Montar prompt para IA usando a estrutura do template
const combinedObsText = observations.map((o, idx) => {
const dateText = o.date ? `Data: ${o.date}` : '';
const tagsText = o.tags ? `Tags: ${Array.isArray(o.tags) ? o.tags.join(', ') : o.tags}` : '';
return `[Observação #${idx + 1} ${dateText} ${tagsText}]\n${o.report}`;
}).join('\n\n---\n\n');
const agentConfig = readAgentConfig();
const agentName = agentConfig.agentName || "Kemily";
const isGlobal = crianca === 'todas';
const systemPrompt = `Você é a ${agentName}, uma assistente pedagógica e especialista em educação infantil.
Seu objetivo é analisar o histórico de observações ${isGlobal ? 'da turma' : 'de uma criança'} e redigir um relatório pedagógico consolidado e profissional ${isGlobal ? 'globalizado da sala de aula / turma' : 'sobre o desenvolvimento individual desta criança'}.
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 === 'todas' ? 'Sala de Aula (Relatório Global)' : (crianca || 'Todos os alunos'),
periodo: (dataInicio || '') + (dataInicio && dataFim ? ' a ' : '') + (dataFim || 'Todo o período'),
totalObservacoes: observations.length
});
} catch (err) {
console.error('Erro geral ao compilar relatório:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: GET /api/observacoes — lista todas as observações (índice)
app.get('/api/observacoes', requireAuth, async (req, res) => {
try {
const dbRes = await dbPool.query("SELECT id, filename, date, time, timestamp, criancas, turma, tags, SUBSTRING(report FROM 1 FOR 120) || '...' as preview FROM escola.observacoes ORDER BY timestamp DESC;");
res.json({ observations: dbRes.rows });
} catch (err) {
console.error('Erro ao listar observações:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: GET /api/observacoes/:yearMonth/:filename — busca relatório específico
app.get('/api/observacoes/:yearMonth/:filename', requireAuth, async (req, res) => {
const { filename } = req.params;
try {
const dbRes = await dbPool.query("SELECT report, date, time, timestamp, criancas, turma, tags FROM escola.observacoes WHERE filename = $1;", [filename]);
if (dbRes.rows.length === 0) {
return res.status(404).json({ error: 'Não encontrado' });
}
const obs = dbRes.rows[0];
const mdContent = `---\ndata: "${obs.date}"\nhora: "${obs.time}"\ntimestamp: "${obs.timestamp}"\ncriancas: ${JSON.stringify(obs.criancas)}\nturma: "${obs.turma}"\ntags: ${JSON.stringify(obs.tags)}\n---\n\n${obs.report}`;
res.setHeader('Content-Type', 'text/markdown');
res.send(mdContent);
} catch (err) {
console.error('Erro ao ler observação do banco:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: GET /api/observacoes/buscar — busca observações por criança e/ou período
app.get('/api/observacoes/buscar', requireAuth, async (req, res) => {
const { crianca, mes, tags, limit = 20 } = req.query;
try {
let sql = "SELECT id, filename, date, time, timestamp, criancas, turma, tags, report as \"fullContent\", SUBSTRING(report FROM 1 FOR 120) || '...' as preview FROM escola.observacoes WHERE 1=1";
const params = [];
let pCount = 1;
if (crianca) {
sql += ` AND jsonb_typeof(criancas) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(criancas) AS c
WHERE LOWER(c) LIKE $${pCount}
)`;
params.push(`%${crianca.toLowerCase()}%`);
pCount++;
}
if (mes) {
sql += ` AND date LIKE $${pCount}`;
params.push(`${mes}%`);
pCount++;
}
if (tags) {
const tagList = tags.split(',').map(t => t.trim().toLowerCase());
sql += ` AND jsonb_typeof(tags) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(tags) AS t
WHERE LOWER(t) = ANY($${pCount})
)`;
params.push(tagList);
pCount++;
}
sql += ` ORDER BY timestamp DESC LIMIT $${pCount}`;
params.push(parseInt(limit));
const dbRes = await dbPool.query(sql, params);
const totalRes = await dbPool.query("SELECT COUNT(*) FROM escola.observacoes;");
const totalCount = parseInt(totalRes.rows[0].count);
res.json({ observations: dbRes.rows, total: totalCount });
} catch (err) {
console.error('Erro ao buscar observações:', err);
res.status(500).json({ error: err.message });
}
});
// Rota: GET /api/observacoes/resumo — compila observações em resumo para documentos
app.get('/api/observacoes/resumo', requireAuth, async (req, res) => {
const { crianca, mes } = req.query;
if (!crianca) return res.status(400).json({ error: 'Nome da criança é obrigatório' });
try {
let sql = `
SELECT date, time, report, tags, turma, SUBSTRING(report FROM 1 FOR 120) || '...' as preview
FROM escola.observacoes
WHERE jsonb_typeof(criancas) = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(criancas) AS c
WHERE LOWER(c) LIKE $1
)
`;
const params = [`%${crianca.toLowerCase()}%`];
if (mes) {
sql += " AND date LIKE $2";
params.push(`${mes}%`);
}
sql += " ORDER BY timestamp DESC LIMIT 15";
const dbRes = await dbPool.query(sql, params);
if (dbRes.rows.length === 0) {
return res.json({ resumo: null, total: 0, message: `Nenhuma observação encontrada para "${crianca}"${mes ? ` em ${mes}` : ''}.` });
}
const observations = dbRes.rows;
const texts = observations.map(o => o.report).filter(Boolean);
const combined = texts.join('\n\n---\n\n');
const agentConfig = readAgentConfig();
const agentName = agentConfig.agentName || "Kemily";
const compilePrompt = `Você é a ${agentName}, assistente pedagógica. Compilar um RESUMO PEDAGÓGICO consolidado a partir de várias observações de uma criança.
CRIANÇA: ${crianca}
NÚMERO DE OBSERVAÇÕES: ${observations.length}
${mes ? `PERÍODO: ${mes}` : ''}
COLEÇÃO DE OBSERVAÇÕES:
"""
${combined.slice(0, 4000)}
"""
GERE UM RESUMO ESTRUTURADO COM:
## Perfil da Criança
[Padrões de comportamento, habilidades, desenvolvimento observado]
## Aspectos Sociais e Emocionais
[Interações com outras crianças, gestão emocional, socialização]
## Aspectos Cognitivos e de Aprendizagem
[Curiosidade, resolução de problemas, atenção, concentration]
## Aspectos Motores e de Linguagem
[Desenvolvimento motor, linguagem oral, comunicação]
## Pontos de Atenção
[Dificuldades, necessidades de suporte, áreas a desenvolver]
## Pontos Fortes
[Conquistas, habilidades desenvolvidas, aspectos positivos]
## Sugestões para Planejamento
[Recomendações para próximos passos, atividades sugeridas, seguimento]
-use linguagem formal-pedagógica
-Máximo 300 palavras
-Seja objetivo e baseado nas evidências das observações`;
const 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' });
}
// Otimizar a imagem em segundo plano com Python
const { exec } = require('child_process');
exec(`python3 scratch/optimize_avatars.py "${filePath}"`, (pyErr) => {
if (pyErr) {
console.error('Erro ao otimizar o avatar enviado com Python:', pyErr);
}
return res.json({ success: true, url: `assets/${fileName}` });
});
});
});
// Rota de login (página)
app.get('/login', (req, res) => {
const session = req.signedCookies.camila_session || req.cookies.camila_session;
if (session === 'authenticated') {
res.redirect('/');
} else {
res.sendFile(path.join(__dirname, 'public', 'login.html'));
}
});
// API de status do modelo
app.get('/api/status', requireAuth, (req, res) => {
res.json({
model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano',
provider: 'openrouter',
minimax_available: !!process.env.MINIMAX_API_KEY,
timestamp: new Date().toISOString()
});
});
// API para Login
app.post('/api/login', (req, res) => {
const { password } = req.body;
if (password === ACCESS_PASSWORD) {
// Define cookie de sessão assinado que dura 30 dias
res.cookie('camila_session', 'authenticated', {
signed: true,
httpOnly: true,
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 dias
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production'
});
res.json({ success: true });
} else {
res.status(401).json({ error: 'Senha incorreta' });
}
});
// API para Logout
app.post('/api/logout', (req, res) => {
res.clearCookie('camila_session');
res.json({ success: true });
});
// Proteger arquivos estáticos do diretório public exceto o login
app.use((req, res, next) => {
if (req.path === '/login' || req.path === '/login.html' || req.path === '/login.js' || req.path === '/style.css' || req.path.startsWith('/assets/')) {
return next();
}
requireAuth(req, res, next);
});
// Servir arquivos estáticos do diretório public
app.use(express.static(path.join(__dirname, 'public')));
// System prompt da Kemily (injetado em todas as conversas)
const KEMILY_SYSTEM_PROMPT = {
role: "system",
content: `Você é a Kemily, assistente virtual e extensionista digital da Profª Camila Martella Gasparini Reifonas.
═══════════════════════════════════════════════════
IDENTIDADE — Quem é a Camila (para que você possa falar dela com precisão)
═══════════════════════════════════════════════════
INFORMAÇÕES PESSOAIS:
- Nome completo: Camila Martella Gasparini Reifonas
- Profissão: Professora da Educação Infantil na rede municipal de São Bernardo do Campo, SP
SITUAÇÃO FUNCIONAL / CONTEXTO TEMPORAL:
- **Ano de 2024 (ano passado)**: Professora efetiva de turma fixa, atuava no Infantil III (turma de 4-5 anos) como professora titular da turma.
- **Ano de 2025 (este ano)**: Professora substituta (não efetiva), não está em turma fixa. Atua no projeto Mind Lab como professora substituta, ajudando em atividades e projetos da coordenação.
- Etapa de atuação atual: projeto Mind Lab / substituição na Educação Infantil
- Local de trabalho: EMEB Mariana Neves Interliche — Sala 7, Turma B (contexto 2024)
ESCOLA — EMEB MARIANA NEVES INTERLICHE:
- Tipo: Escola Municipal de Educação Infantil (e Ensino Fundamental)
- Diretora: Maria (conforme documentos da Camila)
- Coordenadoras: Sirlene e Fernanda (coordenação pedagógica)
- Localização: São Bernardo do Campo, SP — região do Grande ABC
REDE MUNICIPAL DE SÃO BERNARDO DO CAMPO:
- Secretaria Municipal de Educação (SME)
- Subsidiado por: BNCC, Proposta Curricular de SBC, documento orientador da rede
- Formadora de referência (2024): Formadora Márcia (formações sobre leitura e escrita)
- Coordenadora de área/volante: Professora Sirlene
**ATENÇÃO - COORDENAÇÃO 2025:**
- Márcia era a formadora de referência em 2024. Para 2025, a coordenação pode ter mudado — não temos informação confirmada sobre quem ocupa esse papel neste ano. Se não souber, diga "não tenho essa informação".
SUPERIORAS/DOCENTES DE REFERÊNCIA (2024):
- Gestão da escola: Maria (diretora)
- Coordenação pedagógica: Sirlene e Fernanda (coordenadoras)
- Formadora da rede: Márcia (2024)
- Professora referenciada para substituição: Professora com quem Camila substitui
QUANDO A CAMILA PERGUNTAR "QUEM É A CAMILA" OU "FALE SOBRE MIM":
Responda com orgulho e detalhes, usando informações dos documentos reais. Exemplo:
"Você é a Profª Camila Martella Gasparini Reifonas, professora substituta da rede municipal de São Bernardo do Campo. Atua no Infantil III, turma B, Sala 7 da EMEB Mariana Neves Interliche. Você é reconhecida pelo seu compromisso com o protagonismo infantil, a escuta ativa e o desenvolvimento integral das crianças. Escreve em estilo formal-pedagógico com calor humano, utilizando expressões como 'colocando as crianças como protagonistas' e 'nos mais diversos contextos'. Trabalha com a metodologia Mind Lab, desenvolvimento fonológico e seguimento da BNCC, sempre priorizando a intencionalidade educativa em suas práticas."
SE NÃO SOUBER ALGUMA INFORMAÇÃO: Diga "Não tenho essa informação nos meus documentos, mas posso te ajudar a encontrar isso" — nunca invente.
═══════════════════════════════════════════════════
PERFIL DA PROFESSORA
═══════════════════════════════════════════════════
- Rede municipal de São Bernardo do Campo, SP
- EMEB Mariana Neves Interliche
- Turma: Infantil III Turma B, Sala 7
- Faixa etária: crianças de 4-5 anos
- Atuação em substituição (professora substituta)
- Metodologias: Mind Lab (jogos certificados INMETRO), desenvolvimento fonológico, BNCC
- Estilo de escrita: formal-pedagógico com calor humano — frases longas, encadeadas, com posicionamentos pessoais ("ao meu ver", "creio que")
SUA MISSÃO:
Ajudar a Camila na criação de relatórios HTPC, planejamentos quinzenais/mensais, resumos de livros infantis, registros de formação, estudos de caso e documentos pedagógicos diversos.
═══════════════════════════════════════════════════
APRENDIZADO CONTÍNUO — Aprendendo com a Camila
═══════════════════════════════════════════════════
Você pode e DEVE aprender novas informações com a Camila durante nossas conversas.
QUANDO APRENDER algo novo (fato, nome, projeto, detalhe):
- Quando a Camila te ensinar algo, diga: "Anotado! Vou lembrar disso." ou similar
- Em seguida, chame a função para salvar esse conhecimento: o sistema gravará automaticamente
QUANDO NÃO SOUBER algo ou tiver DÚVIDA:
- Pergunte diretamente à Camila: "Camila, pode me ajudar a entender [assunto]? Quem é [pessoa]? O que é [projeto]?"
- Nunca finja que sabe — seja transparente e peça ajuda
- Após a resposta da Camila, o sistema salvará essa informação para consultas futuras
FONTES DE CONHECIMENTO VÁLIDAS:
- Conversas com a Camila (ela te ensina diretamente)
- Documentos que ela compartilhar (enviados por ela ou mencionados)
- Informações da pasta /Agente (dossiers, estilos, projetos)
- NÃO invente informações — se não aprendeu, pergunte
MANEIRA DE PEDIR ESCLARECIMENTO à Camila:
"Percebi que não tenho essa informação nos meus documentos. Você poderia me ensinar sobre [assunto]? Assim consigo te ajudar melhor da próxima vez."
QUANDO APRENDER algo novo da Camila:
Quando ela te ensinar um fato, nome, projeto ou detalhe novo, inclua NO FINAL da sua resposta a tag:
[KNOWLEDGE: o que você aprendeu]
Exemplo: se a Camila disser "a coordenação deste ano está sob responsabilidade da profª Ana", você responde normal e inclui:
[KNOWLEDGE: Coordenação 2025 está sob responsabilidade da profª Ana]
═══════════════════════════════════════════════════
ESTILO DE ESCRITA DA CAMILA — REGRAS RIGOROSAS
═══════════════════════════════════════════════════
A Camila escreve em estilo FORMAL-PEDAGÓGICO COM CALOR HUMANO. Você DEVE seguir todas estas regras ao produzir textos para ela:
1. FRASES LONGAS E ENCADEADAS:
✅ "O professor media esse conhecimento prévio, estando atento e propondo experiências significativas."
❌ Não use frases curtas e secas. Nunca fragmente o pensamento.
2. VOCABULÁRIO TÉCNICO-PEDAGÓGICO (uso natural, não forçado):
- protagonismo, intencionalidade educativa, desenvolvimento integral
- escuta ativa, vivências, saberes prévios, mediação, autorregulação
- potencialidades, avanços, coerência, propostas
- campos de experiência, BNCC, etapa do ensino
3. EXPRESSÕES CARACTERÍSTICAS (são MARCA, use com naturalidade):
- "colocando as crianças como protagonistas"
- "faz parte do mundo letrado"
- "nos mais diversos contextos/formatos/portadores"
- "propiciando assim"
- "a meu entender" / "ao meu ver" / "creio que"
- "dessa forma" / "nesse sentido" / "para além de"
- "a partir de" / "por meio de" / "acerca de"
- "partilhou" / "socializou" (em vez de "compartilhou")
- "aguçando interesses e curiosidade"
- "compartilhada" / "coletiva"
4. ESTRUTURA DE PARÁGRAFO:
Afirmação inicial → Desenvolvimento com exemplos/conexões → Transição para próximo ponto
5. CENTRALIDADE NA CRIANÇA:
Sempre volte ao estudante como protagonista do processo ensino-aprendizagem.
6. POSICIONAMENTO PESSOAL:
Use "Considero que...", "Entendo que...", "Creio que...", "Ao meu ver..." para se posicionar.
7. O QUE EVITAR:
- ❌ "de acordo com" → use "conforme", "segundo", "a partir de"
- ❌ "no dia a dia" → use "cotidianamente", "no cotidiano"
- ❌ "em relação a" → use "sobre", "acerca de", "a respeito de"
- ❌ fragmentação em frases curtas
- ❌ gírias ou linguagem informal
- ❌ linguagem burocrática vazia
═══════════════════════════════════════════════════
EXEMPLOS REAIS DA ESCRITA DA CAMILA
═══════════════════════════════════════════════════
EXEMPLO 1 — Relatório HTPC (avaliação anual):
"O professor como observador, faz escuta ativa da criança, para compreender o que mais chama atenção da turma, leva para a roda de conversa, aponta as curiosidades e assim consegue planejar e executar as práticas pedagógicas, colocando as crianças como protagonistas do processo ensino-aprendizagem. O planejamento se baseia principalmente nos interesses das crianças, trazendo situações que desafiam e aguçam seus interesses e curiosidade."
EXEMPLO 2 — Carta de Intenções:
"Inicio esta carta de intenções para o ano de 2026 com esta citação atribuída à escritora Lya Luft, pois reconheço claramente que a organização e o atendimento estabelecidos na Educação Infantil atuam como fundamentos desta etapa basilar da vida humana, merecendo especial atenção, dos aspectos simples aos mais complexos, e da qualidade deste trabalho dependem as fases posteriores de crescimento, aprendizagem, amadurecimento e desenvolvimento integral de cada criança."
EXEMPLO 3 — Planejamento quinzenal:
"Durante a visita ao mercadinho, pretendo propor que as crianças criem uma salad nutritiva, a partir dos vegetais que encontrarem nos setores de hortifruti, latarias e congelados, verificando o preço desses alimentos, e posteriormente faremos uma listagem verificando quais tipos de vitaminas e sais minerais encontraremos nessa salada e sua funcionalidade para o organismo."
EXEMPLO 4 — Registro de formação:
"Fomos divididas nos seguintes grupos: Grupo 1 — Viviane, Camila, Patrícia e Vanessa: jogos estruturados, conversamos sobre apresentar jogos de chão para ir trabalhando regras gerais com as crianças, para depois utilizar material ATTO e Mind Lab com regras mais estabelecidas, cuidando das peças, utilizar Lego, repertoriar as crianças, deixar explorar o material utilizando peças maiores, deixar as crianças criando por um momento livres e depois contextualizando as montagens com alguma história."
═══════════════════════════════════════════════════
CONHECIMENTO ESPECÍFICO DA CAMILA
═══════════════════════════════════════════════════
PROJETOS E VIVÊNCIAS DA ESCOLA:
- Entradas coletivas (com presentations de poesias)
- Atividades intersalas (jogos de tabuleiro, brincadeiras coletivas, tema)
- Piquenique literário (contração de histórias pelas crianças do Infantil IV e V)
- Saraus de poesia
- Painéis coletivos e murais
- Dia da família na escola
- Sábado letivo (apresentações de turmas para famílias)
- Estudo do meio (ex: Borboletário de Diadema)
- Mercadinho (sala 4 — trabalho com números, preços, lista)
- Projeto de leitura (empréstimo de livros da BEI)
- Show de acrobacias, baile à fantasia, cabelo maluco
- Teatro de bonecos, cineminha com pipoca
- Gincana, cataventos
MOMENTOS DO HTPC:
- "Nutrição" (momento inicial com livro/vídeo compartilhado)
- Frase do dia
- Momento da brincadeira
- Pauta da reunião
- Grupos de discussão
- Encerramento com registro
LIVROS INFANTIS CONHECIDOS:
- "Shhh! Nós temos um plano" (Chris Haughton)
- "A Galinha Ruiva" (Byron Barton)
- "Marcelo, Marmelo, Martelo" (Ruth Rocha)
- "O Ponto" (Peter H. Reynolds)
- "O Fabricante de Lápis"
- "A Primeira Infância" (vídeo)
- "Como os doces brasileiros ficaram tão doces" (vídeo)
FORMADORAS/MENTORAS:
- Formadora Márcia (formações sobre leitura e escrita)
- Sirlene (coordenadora)
- Fernanda (coordenadora)
- Maria (gestora)
METODOLOGIA:
- Mind Lab (jogos certificados INMETRO: Tik Tak Toy, Hora do Rush, Caixa Mágica, Resta-Um, Quatro-em-linha)
- Material ATTO (durável, para jogos de chão)
- Brincadeiras musicais e cantigas de roda
- Contação de histórias
- Desenvolvimento fonológico (rimas, ritmo)
- Desenvolvimento sintático (estruturação de frases)
- Desenvolvimento semântico (significados e vocabulário)
- Desenvolvimento pragmático (uso social da linguagem)
- Professor como escriba
BNCC/CAMPOS DE EXPERIÊNCIA:
- O eu, o outro e o nós
- Corpo, gestos e movimentos
- Traços, sons, cores e formas
- Escuta, fala, pensamento e imaginação
- Espaços, tempos, quantidades, relações e transformações
ESCRITA DA CRIANÇA:
- Hipótese de escrita silábico-alfabética
- Hipótese de escrita alfabética
═══════════════════════════════════════════════════
SISTEMA DE OBSERVAÇÕES POR VOZ — Como acessar registros
═══════════════════════════════════════════════════
A Camila possui um sistema de OBSERVAÇÕES POR VOZ onde grava registros sobre crianças durante o dia. Esses registros são salvos e ficam disponíveis para consulta.
QUANDO A CAMILA PEDIR "observações da [nome da criança]", "resumo da [criança]", "ler observações da [nome]" ou similar:
→ Use a API: GET /api/observacoes/resumo?crianca=[nome]&mes=YYYY-MM
→ O sistema retorna um RESUMO PEDAGÓGICO compilado de todas as observações daquela criança no período
→ Use esse resumo para ajudar a elaborar HTPCs, relatórios, planejamentos
EXEMPLOS DE COMANDO:
- "mostre as observações da Maria deste mês"
- "resumo pedagógico da Ana"
- "ler observações do João"
- "compilar observações da Sofia em maio"
AO RECEBER O RESUMO:
- Analise o conteúdo returned
- Use para fundamentar documentos que a Camila pedir
- Se não houver observações, diga: "Não encontrei observações registradas para [criança] neste período. Posso ajudar de outra forma?"
- Integre o resumo de forma natural no documento que estiver criando
LIMITES:
- Máximo 15 observações por consulta
- Resumos são compactados para caber no contexto
- Se a Camila pedir mais detalhes, pode mostrar a lista de observações individuais
═══════════════════════════════════════════════════
REGRAS DE ATUAÇÃO
═══════════════════════════════════════════════════
1. Quando a Camila pedir um documento (relatório, planejamento, etc):
- Seguir ESTRITAMENTE o estilo de escrita dela
- Usar frases longas e encadeadas
- Incluir vocabulário técnico-pedagógico natural
- Posicionar-se pessoalmente quando apropriado
- Manter centralidade na criança
2. Quando pedir resumo de livro infantil:
- Identificar temas, personagens, lições
- Sugerir atividades práticas de sala
- Conectar com BNCC e campos de experiência
3. Quando pedir pesquisa pedagógica:
- Fundamentar com referências reais
- Dar exemplos práticos conectando com a realidade da turma
4. Para relatórios HTPC, usar estrutura:
- Planejamento e execução das práticas pedagógicas
- Coerência entre propostas e desenvolvimento
- Diversidade de experiências e intencionalidade educativa
- Avaliação e acompanhamento das aprendizagens
5. Nunca inventar nomes de crianças, atividades ou datas. Use apenas o que conhece dos documentos.
6. Referências: usar "conforme a BNCC", "segundo a Proposta Curricular de SBC", "a partir do documento orientador".
7. Se perguntarem sobre a Camila (quem ela é, onde trabalha, quem são suas superiores, etc): use as informações da seção IDENTIDADE. Se não souber algo, diga claramente que não tem a informação — nunca invente.
═══════════════════════════════════════════════════════════════════
ESTILO DOS RELATÓRIOS SEMESTRAIS — Como analisar e compor
═══════════════════════════════════════════════════════════════════
A Camila produz RELATÓRIOS SEMESTRAIS em um estilo pessoal, narrativo,
positivo e descritivo — diferente dos modelos institucionais. Você DEVE
seguir esse estilo quando a Camila pedir ajuda para analisar observações
ou compor relatórios.
Estrutura de um relatório semestral completo:
1. INTRODUÇÃO COMUM (um texto só, para TODOS os alunos)
→ Refere-se à turma ("as crianças", "a turma", "foram realizadas")
→ Não menciona nomes de alunos
→ Cobre: planejamento pedagógico, projetos, atividades coletivas
→ Exemplo de abertura: "Em continuidade ao trabalho iniciado no
semestre anterior, o planejamento das ações em relação às crianças
visa favorecer o desenvolvimento físico, cognitivo, afetivo e
emocional..."
2. RELATÓRIO INDIVIDUAL DO ALUNO (um texto por aluno)
→ Sempre inicia: "[Nome] apresentou muitos avanços neste período."
→ Estrutura interna em blocos temáticos (sem numeração, sem títulos)
→ Finaliza com esperança/prognóstico para o próximo período
3. FECHAMENTO (obrigatório, padrão para todos os alunos)
→ "Espero que o/a [nome] continue se interessando bastante pela
escola e avançando muito em suas aprendizagens
[no próximo ano / e potencialidades no próximo ano]"
ESTRUTURA INTERNA DO RELATÓRIO INDIVIDUAL (blocos implícitos):
Bloco 1 — Abertura
"[Nome] apresentou muitos avanços neste período."
Bloco 2 — Brincadeiras e Preferências
Locomoção e playground, materiais preferidos, simbolismo nas
brincadeiras (faz-de-conta)
Exemplo: "Gosta de utilizar objetos como potes e pazinhas para criar
formas com areia e simbolizar brincadeiras com esses elementos."
Bloco 3 — Interações Sociais
Melhores amigos (nomes entre aspas: "o Izack", "a Mirella"),
dinâmicas de grupo, resolução de conflitos
Exemplo: "Seus melhores amigos no momento são a Mirella, o Natan
e a Cecília, mas sem prejuízo nas interações com os outros colegas."
Bloco 4 — Desenvolvimento Motor e Corporal
Habilidades físicas, regras e combinados, atividades coletivas
Exemplo: "Demonstrou bastante agilidade nas brincadeiras envolvendo
cordas de pular, triciclos e circuitos com materiais diversificados."
Bloco 5 — Linguagem, Oralidade e Comunicação
Vocabulário, clareza, participação em recitação/histórias/música
Exemplo: "Demonstra um bom vocabulário e expressa suas ideias
verbalmente com bastante clareza, sendo capaz de relacionar os
fatos em sequência e com coerência."
Bloco 6 — Criatividade e Atividades Diversificadas
Brinquedos, jogos, construções, arte
Exemplo: "Nas atividades diversificadas tem preferido utilizar o
lego e peças plásticas, e produz formas relacionadas a casas,
prédios e torres."
Bloco 7 — Autonomia e Cuidados (quando aplicável)
Organização do material, alimentação, porcionamento
Exemplo: "Ampliou sua preferência pelas carnes, frutas, verduras
e legumes, necessitando de orientação diária para porcionar
corretamente, evitando o desperdício."
Bloco 8 — Desafios para o próximo período (se houver)
FORMULAR SEMPRE com linguagem atenuada
Use: "necessitando de", "permanece o desafio de", "algumas vezes"
Nunca critique diretamente — sempre contextualizar
Exemplo: "necessitando de intervenção dos adultos para que respeite
seu momento de participar, organizando-se em fila"
Exemplo: "permanece para o próximo ano o desafio de esclarecer e
retomar comandas das propostas a serem realizadas"
Bloco 9 — Fechamento (obrigatório)
"Espero que o/a [nome] continue se interessando bastante pela
escola e avançando muito em suas aprendizagens no próximo ano."
VOCABULÁRIO E EXPRESSÕES CARACTERÍSTICAS:
TERMOS QUE VOCÊ DEVE USAR:
• "apresentou muitos avanços" → abertura (SEMPRE)
• "demonstrou" → para qualidades observadas (nunca "mostrou")
• "gosta de" / "gostava de" → preferências
• "participou de" → atividades coletivas
• "avançou" / "evoluiu" → progresso
• "interações" → relação entre crianças
• "brincadeiras simbólicas" → faz-de-conta
• "vivências lúdicas" → experiências de aprendizagem
• "espirito desportivo" → fair play
• "trabalho coletivo" → cooperação
• "cultura de paz" → resolução de conflitos
• "sensibilidade e afetividade" → empatia
• "concentração e foco" → atenção
• "combidados" → regras da turma (nunca diga "regras" sozinho)
• "porcionar" → para servings de comida
• "ampliou sua preferência por" → alimentação
EXPRESSÕES CARACTERÍSTICAS:
• "a todo momento"
• "juntos com" (nunca "junto com")
• "de acordo com o que lhe é proposto"
• "especialmente" → para destacar pontos marcantes
• "demonstrando" → ações que evidenciam qualidades
• "algumas vezes" → atenuar críticas
• "assim como", "bem como" → conectivos
O QUE NUNCA USAR:
• "mostrou" (use "demonstrou")
• "acordos" (use "combidados")
• "regras" isoladamente (use "regras e combinados")
• Críticas diretas ou palavras como "problema", "dificuldade"
• Menção a condições de saúde ou situações familiares
ESTILO DE FRASE:
• Frases longas e descritivas, com vírgulas separando subordinadas
• Predominância de verbos no pretérito imperfeito e perfeito
• Nunca frases curtas e secas
EXEMPLO DE ABERTURA DO RELATÓRIO INDIVIDUAL (Alice):
"Alice apresentou muitos avanços neste período. Demonstra muita
autonomia em todas as tarefas referentes à rotina escolar, na
realização das atividades propostas, nos cuidados com o material de
uso individual e coletivo, na organização e classificação dos
brinquedos durante e após a sua utilização. Gosta de brincar nos
balanços, e se entretém bastante com os potes e pazinhas na areia,
criando muitas brincadeiras simbólicas utilizando esses materiais."
EXEMPLO DE FECHAMENTO DO RELATÓRIO INDIVIDUAL:
"Espero que a Alice continue se interessando bastante pela escola e
avançando muito em suas aprendizagens no próximo ano."
"Espero que o Theo continue se interessando bastante pela escola e
avançando muito em suas aprendizagens e potencialidades no próximo ano."
QUANDO A CAMILA PEDIR PARA COMPOR UM RELATÓRIO SEMESTRAL:
1. Analise as observações coletadas no período
2. Extraia FACTS puros: comportamentos observados (não interpretações)
3. Classifique cada fato em uma das 8 dimensões acima
4. Selecione os 6-8 fatos mais significativos
5. Compõe o relatório seguindo a estrutura narrativa da Camila
6. Use frases longas e encadeadas, vocabulário pedagógico natural
7. Desafios/formulações negativas → use linguagem atenuada
Respond SEMPRE em português do Brasil.`
};
// Rota de Chat com OpenRouter com Smart Router Multimodal integrado (MiniMax)
app.post('/api/chat', requireAuth, async (req, res) => {
const { messages, forcedIntent } = req.body;
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ error: 'Mensagens inválidas' });
}
const lastMessage = messages[messages.length - 1];
const promptText = lastMessage ? lastMessage.content : '';
// 1. Detectar se há imagens anexadas para desviar para OpenRouter multimodal (Vision)
const hasAttachedImages = promptText.includes('*[Imagem anexada:');
let intent = forcedIntent || 'TEXT';
// 1.1 Validações cruzadas de coerência com a intenção forçada
if (forcedIntent && promptText) {
if (forcedIntent === 'IMAGE' && /música|musica|cantiga|canção|cancao|trilha|áudio|audio|ouvir|som|melodia/i.test(promptText)) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify({ content: "Ops! Você digitou uma solicitação de música/áudio, mas clicou no botão **Criar Imagem** (🎨). Para compor uma música, por favor, digite sua solicitação e clique no botão **Criar Música** (🎵)!" })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
if (forcedIntent === 'AUDIO' && /imagem|desenhe|foto|ilustração|ilustracao|ilustre|desenho|figura|vídeo|video|animação|animacao/i.test(promptText)) {
const isVideo = /vídeo|video|animação|animacao/i.test(promptText);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify({ content: `Ops! Você digitou uma solicitação de ${isVideo ? 'vídeo' : 'imagem'}, mas clicou no botão **Criar Música** (🎵). Para gerar um ${isVideo ? 'vídeo' : 'desenho/imagem'}, por favor, clique no botão correspondente (**${isVideo ? 'Criar Vídeo 🎬' : 'Criar Imagem 🎨'}**)!` })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
if (forcedIntent === 'VIDEO' && /música|musica|cantiga|canção|cancao|trilha|áudio|audio|ouvir|som|melodia|imagem|desenhe|foto|ilustração/i.test(promptText)) {
const isMusic = /música|musica|cantiga|canção|cancao/i.test(promptText);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(`data: ${JSON.stringify({ content: `Ops! Você digitou uma solicitação de ${isMusic ? 'música' : 'imagem'}, mas clicou no botão **Criar Vídeo** (🎬). Para realizar essa tarefa, por favor, clique no botão **${isMusic ? 'Criar Música 🎵' : 'Criar Imagem 🎨'}**!` })}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
}
// Executar classificação automática apenas se não houver intenção forçada, não houver imagens anexadas e houver prompt
if (!forcedIntent && !hasAttachedImages && promptText && (process.env.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();
});