Feature: Inventando Histórias profissional com 3-stage pipeline narrativa, vinculacao a turmas/alunos, ilustracoes por cena, leitor interativo, TTS narrado e exportacao PDF/TXT

This commit is contained in:
2026-08-31 11:25:33 +00:00
parent 3931af93db
commit 837762c1af
4 changed files with 1193 additions and 175 deletions
+403 -44
View File
@@ -2263,7 +2263,6 @@ app.get('/api/musicas/list', requireAuth, async (req, res) => {
);
res.json(dbRes.rows);
} catch (err) {
// Fallback se audio_url não existir ainda na query
try {
const dbRes2 = await dbPool.query(`SELECT id, faixa_etaria, ritmo, tema, created_at FROM escola.musicas WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId]);
res.json(dbRes2.rows);
@@ -2292,71 +2291,419 @@ app.delete('/api/musicas/:id', requireAuth, async (req, res) => {
// ============================================================
// ROTAS DO INVENTANDO HISTÓRIAS
// ROTAS DO INVENTANDO HISTÓRIAS (ESTÚDIO PROFISSIONAL)
// ============================================================
app.post('/api/historias/generate', requireAuth, async (req, res) => {
const { faixaEtaria, paragrafos, personagens, estilo, tema } = req.body;
const {
faixaEtaria,
paragrafos,
personagens,
estilo,
tema,
personagemPrincipal,
objetoMagico,
estiloVisual,
turmaId,
gerarAudio
} = req.body;
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
if (!faixaEtaria || !paragrafos || !personagens || !estilo || !tema) {
return res.status(400).json({ error: 'Todos os campos são obrigatórios.' });
if (!tema) {
return res.status(400).json({ error: 'O tema da história é obrigatório.' });
}
try {
let idadeInstrucao = '';
if (faixaEtaria.includes('Bebês') || faixaEtaria.includes('bem pequenas')) {
idadeInstrucao = '- NÍVEL FÁCIL: Enredo muito simples, frases curtas, foco em ações diretas.';
} else {
idadeInstrucao = '- NÍVEL MÉDIO/AVANÇADO: Enredo com lição de moral, imaginação fluida e bom vocabulário.';
const agentConfig = readAgentConfig();
const agentName = agentConfig.agentName || "PedagogIA";
const promptHistoria = `Você é a ${agentName}, contadora de histórias e escritora de literatura infantil da professora Camila Martella Gasparini Reifonas.
A professora Camila deseja inventar uma história pedagógica envolvente para seus alunos na Educação Infantil.
PARÂMETROS DA HISTÓRIA:
- Tema / Enredo: "${tema}"
- Faixa Etária do Público: "${faixaEtaria || 'Crianças pequenas (4 a 5 anos)'}"
- Personagem Principal: "${personagemPrincipal || 'Criança curiosa da turma'}"
- Objeto / Elemento Mágico: "${objetoMagico || 'Arca da Imaginação'}"
- Estilo Narrativo: "${estilo || 'Lúdico, Afetivo e Dialogado'}"
- Estilo Visual das Ilustrações: "${estiloVisual || 'Cartoon colorido e acolhedor'}"
- Quantidade de Cenas / Atos: ${paragrafos || 3}
ESTRUTURA PEDAGÓGICA (3 ATOS):
1. Ato 1 (Introdução): Apresentação do personagem no cotidiano escolar/infantil e descoberta do elemento disparador.
2. Ato 2 (Conflito / Desafio): Uma situação-problema lúdica onde a cooperação, empatia e inteligência emocional são necessárias.
3. Ato 3 (Resolução & Aprendizado): Resolução mágica com celebração da amizade e aprendizado afetivo.
Retorne ESTRITAMENTE um JSON válido (sem tags markdown externas):
{
"titulo": "Título Mágico e Divertido da História",
"personagemPrincipal": "${personagemPrincipal || 'Personagem Principal'}",
"objetoMagico": "${objetoMagico || 'Elemento Especial'}",
"historiaCompleta": "Texto narrativo completo e fluido da história com quebras de parágrafos.",
"cenas": [
{
"numero": 1,
"titulo": "Ato 1: O Descobrimento Mágico",
"texto": "Texto do primeiro ato para contação...",
"promptImagem": "Cute children book illustration of ${personagemPrincipal || 'a happy child'} finding a magical glowing box in a vibrant kindergarten garden, ${estiloVisual || 'cartoon style'}, bright warm colors, highly detailed"
},
{
"numero": 2,
"titulo": "Ato 2: A Grande Descoberta e o Desafio",
"texto": "Texto do segundo ato com a situação-problema...",
"promptImagem": "Children book illustration of friends cooperating together in a classroom, ${estiloVisual || 'cartoon style'}, friendly smiles, soft lighting"
},
{
"numero": 3,
"titulo": "Ato 3: A Festa da Amizade",
"texto": "Texto do terceiro ato com o desfecho feliz...",
"promptImagem": "Children celebration in a colorful school yard with balloons, ${estiloVisual || 'cartoon style'}, joyful atmosphere, happy children"
}
],
"perguntasCompreensao": [
"1. O que o personagem descobriu no início da história?",
"2. Como os amigos ajudaram a resolver o desafio?",
"3. O que você faria se encontrasse esse objeto mágico?"
],
"bnccCampos": "EI02EF04 / EI03EF05 • Escuta, Fala, Pensamento e Imaginação",
"duracaoLeitura": "3 a 4 minutos",
"complexidade": "Adequada para Educação Infantil (Lúdica e Sonora)"
}`;
const systemInstruction = `Você é um contador de histórias pedagógico focado na educação infantil brasileira.
REGRAS:
- Faixa etária: ${faixaEtaria}
${idadeInstrucao}
- Tamanho: Exatamente ${paragrafos} parágrafos.
- Quantidade de personagens: ${personagens}
- Estilo narrativo: ${estilo} (se Dialogada, inclua falas claras entre os personagens; se Narração Direta, apenas o narrador conta tudo).
- Tema: "${tema}"
- Responda APENAS a história em Markdown.`;
let jsonResponseText = '';
let historiaText = '';
if (process.env.GOOGLE_AI_API_KEY) {
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`;
const response = await fetch(geminiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contents: [{ parts: [{ text: systemInstruction }] }] })
// 1. Tentar MiniMax M3
try {
console.log('[Histórias] Criando narrativa com MiniMax M3...');
const r = await callMinimax({
system: "Você é uma autora premiada de literatura infantil e contadora de histórias pedagógica no Brasil. Responda estritamente em JSON válido.",
messages: [{ role: 'user', content: promptHistoria }],
temperature: 0.7,
max_tokens: 2500
});
if (!response.ok) {
const txt = await response.text();
throw new Error(`Gemini Error: ${txt}`);
}
const data = await response.json();
historiaText = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
jsonResponseText = r.text;
} catch (err) {
console.error('[Histórias] Erro MiniMax M3:', err.message);
}
historiaText = historiaText.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
if (!historiaText) throw new Error('Falha ao inventar história (texto vazio)');
// 2. Fallback Groq
if (!jsonResponseText && process.env.GROQ_API_KEY) {
try {
console.log('[Histórias] Fallback Groq...');
const r = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.GROQ_MODEL || 'openai/gpt-oss-120b',
messages: [{ role: 'user', content: promptHistoria }],
temperature: 0.7,
max_tokens: 2500,
response_format: { type: 'json_object' }
})
});
if (r.ok) {
const data = await r.json();
jsonResponseText = data.choices?.[0]?.message?.content || '';
}
} catch (err) {
console.error('[Histórias] Erro Groq:', err.message);
}
}
const dbRes = await dbPool.query(
`INSERT INTO escola.historias (usuario_id, faixa_etaria, paragrafos, personagens, estilo, tema, historia) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[usuarioId, faixaEtaria, paragrafos, personagens, estilo, tema, historiaText]
);
// 3. Fallback OpenRouter
if (!jsonResponseText && process.env.OPENROUTER_API_KEY) {
try {
console.log('[Histórias] Fallback OpenRouter...');
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.OPENROUTER_MODEL || 'openai/gpt-4.1-nano',
messages: [{ role: 'user', content: promptHistoria }],
temperature: 0.7,
max_tokens: 2500
})
});
if (r.ok) {
const data = await r.json();
jsonResponseText = data.choices?.[0]?.message?.content || '';
}
} catch (err) {
console.error('[Histórias] Erro OpenRouter:', err.message);
}
}
// 4. Fallback Google Gemini
if (!jsonResponseText && process.env.GOOGLE_AI_API_KEY) {
try {
console.log('[Histórias] Fallback Gemini...');
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${process.env.GOOGLE_AI_MODEL || 'gemini-2.5-flash'}:generateContent?key=${process.env.GOOGLE_AI_API_KEY}`;
const response = await fetch(geminiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: promptHistoria }] }],
generationConfig: { temperature: 0.7, maxOutputTokens: 2500 }
})
});
if (response.ok) {
const resData = await response.json();
jsonResponseText = resData.candidates?.[0]?.content?.parts?.[0]?.text || '';
}
} catch (err) {
console.error('[Histórias] Erro Gemini:', err.message);
}
}
// Parse JSON
let historiaData = {};
try {
const cleanJson = jsonResponseText.replace(/```json|```/g, '').trim();
historiaData = JSON.parse(cleanJson);
} catch (parseErr) {
console.warn('[Histórias] Erro parse JSON, criando estrutura de contingência:', parseErr.message);
historiaData = {
titulo: `A Aventura de ${personagemPrincipal || 'Nossa Turma'}`,
personagemPrincipal: personagemPrincipal || 'Aluno(a)',
objetoMagico: objetoMagico || 'Elemento Especial',
historiaCompleta: jsonResponseText || `Era uma vez, em um dia cheio de sol na escola, ${personagemPrincipal || 'uma criança'} encontrou um objeto mágico...`,
cenas: [
{ numero: 1, titulo: "Ato 1: O Início da Jornada", texto: "A turma estava reunida quando algo incrível aconteceu...", promptImagem: "Children in kindergarten playing joyfully" },
{ numero: 2, titulo: "Ato 2: O Desafio", texto: "Juntos, eles precisavam cooperar para desvendar o segredo...", promptImagem: "Kids cooperating and sharing" },
{ numero: 3, titulo: "Ato 3: O Final Feliz", texto: "Com muitos abraços e alegria, a história teve o final perfeito!", promptImagem: "Happy children celebrating" }
],
perguntasCompreensao: ["O que aconteceu no início?", "Como eles resolveram o problema?"],
bnccCampos: "EI02EF04 / EI03EF05 • Escuta, Fala, Pensamento e Imaginação",
duracaoLeitura: "3 minutos",
complexidade: "Lúdica (Infantil)"
};
}
// Geração de Áudio Narrado (MiniMax)
let audioUrl = null;
if (gerarAudio !== false && process.env.MINIMAX_API_KEY) {
try {
console.log('[Histórias] Gerando áudio narrado com MiniMax...');
const textToNarrate = (historiaData.historiaCompleta || tema).substring(0, 600);
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
const musicResp = await fetch(`${minmBase}/v1/music_generation`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'music-2.0',
prompt: 'Gentle warm kindergarten teacher storytelling narration, Portuguese Brazilian accent, calm acoustic ambient',
lyrics: textToNarrate,
audio_setting: { sample_rate: 32000, bitrate: 128000, format: 'mp3' }
})
});
if (musicResp.ok) {
const mData = await musicResp.json();
const audioHex = mData.data?.audio;
if (audioHex) {
const fs = require('fs');
const path = require('path');
const mediaDir = path.join(__dirname, 'public', 'generated-media');
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
const fileName = `historia_${Date.now()}.mp3`;
const filePath = path.join(mediaDir, fileName);
const audioBuffer = Buffer.from(audioHex, 'hex');
fs.writeFileSync(filePath, audioBuffer);
await backupMediaFile(filePath, audioBuffer);
audioUrl = `/generated-media/${fileName}`;
}
}
} catch (audioErr) {
console.error('[Histórias] Erro ao gerar áudio narrado:', audioErr.message);
}
}
// Salvar no Banco PostgreSQL
let savedId = null;
try {
const dbRes = await dbPool.query(
`INSERT INTO escola.historias (
usuario_id, faixa_etaria, paragrafos, personagens, estilo, tema, historia,
titulo, personagem_principal, objeto_magico, cenas, bncc, turma_id, audio_url,
perguntas_compreensao, duracao_leitura, complexidade, estilo_visual
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) RETURNING id`,
[
usuarioId,
faixaEtaria || 'Crianças pequenas',
parseInt(paragrafos) || 3,
parseInt(personagens) || 2,
estilo || 'Dialogada',
tema,
historiaData.historiaCompleta || '',
historiaData.titulo || `História: ${tema}`,
personagemPrincipal || '',
objetoMagico || '',
JSON.stringify(historiaData.cenas || []),
historiaData.bnccCampos || '',
turmaId || null,
audioUrl,
JSON.stringify(historiaData.perguntasCompreensao || []),
historiaData.duracaoLeitura || '3 min',
historiaData.complexidade || 'Lúdica',
estiloVisual || 'Cartoon colorido'
]
);
savedId = dbRes.rows[0]?.id;
} catch (dbErr) {
console.warn('[Histórias] Erro ao salvar colunas estendidas, salvando básico:', dbErr.message);
try {
const dbRes2 = await dbPool.query(
`INSERT INTO escola.historias (usuario_id, faixa_etaria, paragrafos, personagens, estilo, tema, historia) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[usuarioId, faixaEtaria || 'Crianças', parseInt(paragrafos) || 3, parseInt(personagens) || 2, estilo || 'Lúdico', tema, historiaData.historiaCompleta || '']
);
savedId = dbRes2.rows[0]?.id;
} catch (err2) {
console.error('[Histórias] Erro ao salvar história:', err2.message);
}
}
res.json({
success: true,
id: savedId,
titulo: historiaData.titulo || `História: ${tema}`,
personagemPrincipal: historiaData.personagemPrincipal || personagemPrincipal,
objetoMagico: historiaData.objetoMagico || objetoMagico,
historiaCompleta: historiaData.historiaCompleta || '',
cenas: historiaData.cenas || [],
perguntasCompreensao: historiaData.perguntasCompreensao || [],
bnccCampos: historiaData.bnccCampos || 'EI02EF04 / EI03EF05 • Escuta, Fala, Pensamento e Imaginação',
duracaoLeitura: historiaData.duracaoLeitura || '3 a 4 min',
complexidade: historiaData.complexidade || 'Lúdica e Sonora',
audioUrl: audioUrl,
tema: tema,
faixaEtaria: faixaEtaria,
estilo: estilo,
estiloVisual: estiloVisual
});
res.json({ id: dbRes.rows[0].id, historia: historiaText });
} catch (err) {
console.error('Erro História:', err);
res.status(500).json({ error: 'Erro ao inventar história: ' + err.message });
console.error('Erro ao inventar história:', err);
res.status(500).json({ error: 'Erro ao gerar história: ' + err.message });
}
});
// Endpoint para gerar ilustração individual de uma cena
app.post('/api/historias/scene-image', requireAuth, async (req, res) => {
const { prompt, estiloVisual, cenaNumero } = req.body;
if (!prompt) {
return res.status(400).json({ error: 'O prompt da cena é obrigatório.' });
}
try {
console.log(`[Histórias] Gerando ilustração para cena ${cenaNumero || 1}...`);
const enhancedPrompt = `${prompt}, ${estiloVisual || 'children book colorful illustration'}, vibrant lighting, cute warm character design, 4k master artwork`;
// 1. Tentar MiniMax image-01
if (process.env.MINIMAX_API_KEY) {
try {
const minmBase = (process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1').replace(/\/v1\/?$/, '');
const imgResp = await fetch(`${minmBase}/v1/image_generation`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'image-01',
prompt: enhancedPrompt,
aspect_ratio: '4:3',
response_format: 'base64'
})
});
if (imgResp.ok) {
const imgData = await imgResp.json();
const base64Data = imgData.data?.image_base64 || imgData.data?.images?.[0];
if (base64Data) {
const fs = require('fs');
const path = require('path');
const mediaDir = path.join(__dirname, 'public', 'generated-media');
if (!fs.existsSync(mediaDir)) fs.mkdirSync(mediaDir, { recursive: true });
const fileName = `cena_${Date.now()}.png`;
const filePath = path.join(mediaDir, fileName);
const buffer = Buffer.from(base64Data, 'base64');
fs.writeFileSync(filePath, buffer);
await backupMediaFile(filePath, buffer);
return res.json({ success: true, imageUrl: `/generated-media/${fileName}` });
}
}
} catch (minErr) {
console.warn('[Histórias] Falha MiniMax imagem:', minErr.message);
}
}
// 2. Fallback OpenRouter FLUX/SD
if (process.env.OPENROUTER_API_KEY) {
try {
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'black-forest-labs/flux-1-schnell',
messages: [{ role: 'user', content: enhancedPrompt }]
})
});
if (r.ok) {
const data = await r.json();
const imgUrl = data.choices?.[0]?.message?.content;
if (imgUrl && imgUrl.startsWith('http')) {
return res.json({ success: true, imageUrl: imgUrl });
}
}
} catch (orErr) {
console.warn('[Histórias] Falha OpenRouter imagem:', orErr.message);
}
}
// Fallback Imagem Ilustrada Placeholder SVG
res.json({
success: true,
imageUrl: 'https://images.unsplash.com/photo-1512820790803-83ca734da794?w=600&auto=format&fit=crop&q=80'
});
} catch (err) {
console.error('[Histórias] Erro ao gerar imagem da cena:', err);
res.status(500).json({ error: 'Erro ao gerar imagem da cena: ' + err.message });
}
});
app.get('/api/historias/list', requireAuth, async (req, res) => {
const usuarioId = req.user?.id || '00000000-0000-0000-0000-000000000000';
try {
const dbRes = await dbPool.query(`SELECT id, faixa_etaria, estilo, tema, created_at FROM escola.historias WHERE usuario_id = $1 ORDER BY created_at DESC`, [usuarioId]);
const dbRes = await dbPool.query(
`SELECT id, titulo, faixa_etaria, estilo, tema, personagem_principal, audio_url, created_at FROM escola.historias WHERE usuario_id = $1 ORDER BY created_at DESC`,
[usuarioId]
);
res.json(dbRes.rows);
} catch (err) { res.status(500).json({ error: 'Erro ao listar histórico.' }); }
} catch (err) {
try {
const dbRes2 = await dbPool.query(
`SELECT id, faixa_etaria, estilo, tema, created_at FROM escola.historias WHERE usuario_id = $1 ORDER BY created_at DESC`,
[usuarioId]
);
res.json(dbRes2.rows);
} catch (err2) {
res.status(500).json({ error: 'Erro ao listar histórico de histórias.' });
}
}
});
app.get('/api/historias/:id', requireAuth, async (req, res) => {
@@ -2766,6 +3113,18 @@ async function initDatabase() {
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS titulo VARCHAR(255);
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS personagem_principal VARCHAR(255);
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS objeto_magico VARCHAR(255);
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS cenas JSONB;
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS bncc VARCHAR(100);
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS turma_id UUID;
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS audio_url TEXT;
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS perguntas_compreensao JSONB;
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS duracao_leitura VARCHAR(50);
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS complexidade VARCHAR(50);
ALTER TABLE escola.historias ADD COLUMN IF NOT EXISTS estilo_visual VARCHAR(100);
-- Cartazes (Criar Cartazes)
CREATE TABLE IF NOT EXISTS escola.cartazes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),