Initial commit: summary.edemium.ru - meeting summary generator
This commit is contained in:
481
server.js
Normal file
481
server.js
Normal file
@@ -0,0 +1,481 @@
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const OpenAI = require('openai');
|
||||
const multer = require('multer');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3004;
|
||||
const PASSWORD = process.env.PASSWORD || '1928812Edemium';
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const BOT_TOKEN = process.env.BOT_TOKEN || '';
|
||||
const CHAT_ID = process.env.CHAT_ID || '';
|
||||
const SITES_DIR = process.env.SITES_DIR || '/sites/summaries';
|
||||
|
||||
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
|
||||
const DEEPGRAM_KEY = process.env.DEEPGRAM_KEY || '';
|
||||
const uploadSmall = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
||||
const uploadLarge = multer({ storage: multer.memoryStorage(), limits: { fileSize: 200 * 1024 * 1024 } });
|
||||
|
||||
fs.mkdirSync(SITES_DIR, { recursive: true });
|
||||
|
||||
const TEMPLATE = fs.readFileSync(path.join(__dirname, 'template-meeting.html'), 'utf-8');
|
||||
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
app.use(session({
|
||||
secret: 'summary-builder-edemium-2026',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: { maxAge: 24 * 60 * 60 * 1000 }
|
||||
}));
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session && req.session.authenticated) return next();
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
app.post('/api/auth', (req, res) => {
|
||||
if (req.body.password === PASSWORD) {
|
||||
req.session.authenticated = true;
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(403).json({ error: 'Wrong password' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/check-auth', (req, res) => {
|
||||
res.json({ authenticated: !!(req.session && req.session.authenticated) });
|
||||
});
|
||||
|
||||
// Build system prompt for meeting summary generation
|
||||
function buildSystemPrompt(meetingType) {
|
||||
return `Ты — генератор HTML-саммари встреч в стиле WORD-ДОКУМЕНТА.
|
||||
|
||||
КОНТЕКСТ:
|
||||
Тебе дают транскрипт или заметки со встречи. Ты должен проанализировать содержание и создать структурированное саммари.
|
||||
Тип встречи: ${meetingType || 'общая встреча'}
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Ответ — ТОЛЬКО HTML-код, без markdown, без \`\`\`, без пояснений
|
||||
2. Копируй CSS и паттерны из шаблона ниже ТОЧНО (стили, классы, структуру)
|
||||
3. Это СТАТИЧНЫЙ документ — никаких form, input, textarea, кнопок
|
||||
4. Цветовая схема: синий --accent: #2563eb, --accent-dark: #1e40af
|
||||
5. Обязательно @media print стили
|
||||
|
||||
КРИТИЧЕСКИ ВАЖНЫЙ СТИЛЬ — ДОКУМЕНТНЫЙ, КАК В WORD:
|
||||
- НЕ используй border-radius НИГДЕ
|
||||
- НЕ используй карточки (card), тени на элементах, цветные подложки
|
||||
- Чеклисты — текстовый символ ✓ через ::before
|
||||
- Таблицы — тёмный заголовок (--accent-dark), центрированный текст (кроме 1-го столбца — left)
|
||||
- Всё должно выглядеть как документ из Microsoft Word / Google Docs
|
||||
|
||||
ФОРМАТ:
|
||||
- body background: #f0f1f3
|
||||
- Контент в блоках .doc-page — белые A4-страницы (width:210mm, min-height:297mm, padding:50px 60px)
|
||||
- Обложка — .doc-page.cover-page
|
||||
- Контент — 1-2 блока .doc-page
|
||||
|
||||
УНИВЕРСАЛЬНАЯ СТРУКТУРА:
|
||||
НЕ используй фиксированный набор секций. Анализируй содержание и включай ТОЛЬКО релевантные разделы:
|
||||
|
||||
ОБЯЗАТЕЛЬНЫЕ разделы (всегда):
|
||||
1. Обложка (.cover-page): заголовок "САММАРИ ВСТРЕЧИ", тема, дата, участники
|
||||
2. Контекст встречи — о чём и зачем (2-3 предложения)
|
||||
3. Ключевые темы — таблица (тема | суть обсуждения)
|
||||
4. Действия — таблица (задача | исполнитель | срок). Извлеки конкретные задачи с именами и сроками.
|
||||
5. Следующие шаги — нумерованный список
|
||||
|
||||
ОПЦИОНАЛЬНЫЕ разделы (включай если есть в транскрипте):
|
||||
- Решения — таблица (решение | ответственный | комментарий)
|
||||
- Возражения/Вопросы — таблица (вопрос | кто | ответ). Особенно для встреч типа "продажа"
|
||||
- Обсуждённые цены/бюджеты — таблица. Для продаж и брифингов
|
||||
- Ключевые цитаты — блоки .quote-item с .quote-text и .quote-author
|
||||
- Рекомендации — checklist с ✓
|
||||
|
||||
АДАПТАЦИЯ ПО ТИПУ ВСТРЕЧИ:
|
||||
- "продажа" → акцент на возражения, цены, следующие шаги по закрытию
|
||||
- "брифинг" → акцент на требования, технические детали, бюджет
|
||||
- "интервью" → акцент на ключевые ответы (таблица вопрос|ответ), впечатления
|
||||
- "стратегия" → акцент на стратегические решения, цели, метрики
|
||||
- "общая" → сбалансированная структура
|
||||
|
||||
ВАЖНО:
|
||||
- Извлекай КОНКРЕТИКУ: имена, даты, цифры, суммы
|
||||
- Не выдумывай информацию — если чего-то нет в транскрипте, не включай
|
||||
- Если транскрипт короткий — делай компактное саммари (1 страница контента)
|
||||
- Если транскрипт длинный — подробное (2-3 страницы)
|
||||
- Всё на русском языке
|
||||
|
||||
ШАБЛОН (БЕРИ СТИЛИ И ПАТТЕРНЫ ТОЧНО):
|
||||
|
||||
${TEMPLATE}
|
||||
|
||||
Генерируй саммари встречи.`;
|
||||
}
|
||||
|
||||
// Generate summary (SSE streaming)
|
||||
app.post('/api/generate-stream', requireAuth, async (req, res) => {
|
||||
const { transcript, meetingTitle, meetingDate, participants, meetingType, context } = req.body;
|
||||
|
||||
if (!transcript && !context) {
|
||||
return res.status(400).json({ error: 'Нужен транскрипт или контекст встречи' });
|
||||
}
|
||||
|
||||
let userMsg = '';
|
||||
if (meetingTitle) userMsg += `Тема встречи: ${meetingTitle}\n`;
|
||||
if (meetingDate) userMsg += `Дата: ${meetingDate}\n`;
|
||||
if (participants) userMsg += `Участники: ${participants}\n`;
|
||||
if (context) userMsg += `Контекст/цели: ${context}\n`;
|
||||
userMsg += `\nТРАНСКРИПТ ВСТРЕЧИ:\n${transcript || '(транскрипт не предоставлен, используй контекст выше)'}`;
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
try {
|
||||
const stream = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: buildSystemPrompt(meetingType) },
|
||||
{ role: 'user', content: userMsg }
|
||||
],
|
||||
max_tokens: 16000,
|
||||
temperature: 0.5,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let fullHtml = '';
|
||||
let skipPrefix = true;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.choices[0]?.delta?.content;
|
||||
if (!content) continue;
|
||||
fullHtml += content;
|
||||
|
||||
if (skipPrefix) {
|
||||
const trimmed = fullHtml.replace(/^```html?\n?/i, '');
|
||||
if (trimmed !== fullHtml) {
|
||||
fullHtml = trimmed;
|
||||
skipPrefix = false;
|
||||
if (trimmed.length > 0) {
|
||||
res.write(`data: ${JSON.stringify({ chunk: trimmed })}\n\n`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (fullHtml.length > 10) skipPrefix = false;
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ chunk: content })}\n\n`);
|
||||
}
|
||||
|
||||
fullHtml = fullHtml.replace(/\n?```$/i, '').trim();
|
||||
res.write(`data: ${JSON.stringify({ done: true, html: fullHtml })}\n\n`);
|
||||
res.end();
|
||||
|
||||
// Telegram notification
|
||||
notifyTelegram(meetingTitle || 'Без темы', meetingType || 'общая');
|
||||
} catch (err) {
|
||||
console.error('Stream error:', err);
|
||||
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Refine summary via chat
|
||||
app.post('/api/refine', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { html, message } = req.body;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Ты — редактор HTML саммари встреч в стиле Word-документа.
|
||||
Тебе дают текущий HTML-код саммари и инструкцию по правке.
|
||||
Верни ПОЛНЫЙ обновлённый HTML-код (не фрагмент, а весь файл).
|
||||
ТОЛЬКО HTML-код, без markdown, без \`\`\`, без пояснений.
|
||||
Сохраняй формат документа: .doc-page блоки (A4 страницы), обложку, секции, таблицы, doc-footer.
|
||||
Это СТАТИЧНЫЙ документ — никаких form, input, textarea, кнопок.
|
||||
СТИЛЬ — как Word: без border-radius, таблицы с центрированным текстом и тёмным заголовком.`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Текущий HTML:\n\n${html}\n\nВнеси правку: ${message}`
|
||||
}
|
||||
],
|
||||
max_tokens: 16000,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
let newHtml = response.choices[0].message.content;
|
||||
newHtml = newHtml.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '').trim();
|
||||
res.json({ ok: true, html: newHtml });
|
||||
} catch (err) {
|
||||
console.error('Refine error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Transcribe audio via Deepgram
|
||||
async function transcribeAudio(buffer, mime) {
|
||||
const dgRes = await fetch(
|
||||
'https://api.deepgram.com/v1/listen?model=nova-2&language=ru&smart_format=true&punctuate=true',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Token ' + DEEPGRAM_KEY,
|
||||
'Content-Type': mime || 'audio/webm',
|
||||
},
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
if (!dgRes.ok) {
|
||||
const errText = await dgRes.text();
|
||||
throw new Error('Deepgram error: ' + dgRes.status + ' ' + errText);
|
||||
}
|
||||
const dgData = await dgRes.json();
|
||||
return dgData.results?.channels?.[0]?.alternatives?.[0]?.transcript || '';
|
||||
}
|
||||
|
||||
// Extract meeting fields from transcript via GPT
|
||||
const EXTRACT_SYSTEM_PROMPT = `Ты — парсер для Summary Builder (генератор саммари встреч).
|
||||
|
||||
Тебе дают текст — это ЗАПИСЬ ВСТРЕЧИ или ДИАЛОГ.
|
||||
Проанализируй и извлеки метаданные.
|
||||
|
||||
КРИТИЧЕСКОЕ ПРАВИЛО — ПУСТЫЕ ПОЛЯ:
|
||||
Если ТОЧНОЕ значение НЕ прозвучало в тексте — поле ОБЯЗАНО быть "".
|
||||
ЗАПРЕЩЕНО выдумывать, угадывать или подставлять общие слова.
|
||||
|
||||
Верни ТОЛЬКО JSON без markdown:
|
||||
{
|
||||
"meetingTitle": "Тема встречи — краткое название" или "",
|
||||
"participants": "Имена участников через запятую" или "",
|
||||
"meetingType": "одно из: общая, продажа, брифинг, интервью, стратегия",
|
||||
"context": "Подробный контекст — 3-5 предложений о чём встреча, цели, ключевые моменты",
|
||||
"keyTopics": ["Тема 1", "Тема 2", "Тема 3"]
|
||||
}
|
||||
|
||||
ВАЖНО:
|
||||
- meetingType определяй по содержанию: если обсуждают покупку/продажу/цены → "продажа", если требования к проекту → "брифинг", если вопрос-ответ → "интервью", если планы/стратегия → "стратегия", иначе → "общая"
|
||||
- keyTopics — основные темы, которые обсуждались (3-7 штук)
|
||||
- context — МАКСИМАЛЬНО подробно, что обсуждали, кто что сказал, какие проблемы/решения
|
||||
- Правило: не знаешь точное значение → пустая строка ""`;
|
||||
|
||||
async function extractFieldsFromText(text, currentFields) {
|
||||
let inputText = text;
|
||||
if (text.length > 12000) {
|
||||
const summary = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: 'Суммаризируй транскрипт встречи ПОДРОБНО. Сохрани: имена участников, темы, решения, действия, возражения, цитаты, даты, цифры. 2000-3000 слов.' },
|
||||
{ role: 'user', content: text.substring(0, 60000) }
|
||||
],
|
||||
temperature: 0.2,
|
||||
max_tokens: 4000,
|
||||
});
|
||||
inputText = summary.choices[0].message.content;
|
||||
}
|
||||
|
||||
let userContent = inputText;
|
||||
if (currentFields) {
|
||||
userContent = `ТЕКУЩЕЕ СОСТОЯНИЕ ПОЛЕЙ:\n${currentFields}\n\nНОВЫЙ ВВОД (дополни/обнови):\n${inputText}`;
|
||||
}
|
||||
|
||||
const extraction = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: EXTRACT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userContent }
|
||||
],
|
||||
temperature: 0.2,
|
||||
max_tokens: 1500,
|
||||
});
|
||||
|
||||
let content = extraction.choices[0].message.content;
|
||||
content = content.replace(/^```json?\n?/i, '').replace(/\n?```$/i, '').trim();
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
// Transcribe only (chat mic)
|
||||
app.post('/api/transcribe', requireAuth, uploadSmall.single('audio'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No audio' });
|
||||
const text = await transcribeAudio(req.file.buffer, req.file.mimetype);
|
||||
res.json({ ok: true, text });
|
||||
} catch (err) {
|
||||
console.error('Transcribe error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Voice fill — mic -> Deepgram -> GPT field extraction
|
||||
app.post('/api/voice-fill', requireAuth, uploadSmall.single('audio'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No audio file' });
|
||||
const currentFields = req.body.currentFields || null;
|
||||
const text = await transcribeAudio(req.file.buffer, req.file.mimetype);
|
||||
console.log('Voice transcript:', text.substring(0, 200));
|
||||
|
||||
let parsed;
|
||||
try { parsed = await extractFieldsFromText(text, currentFields); }
|
||||
catch (e) {
|
||||
console.error('Extract fields error (voice):', e.message);
|
||||
return res.json({ ok: true, transcript: text, fields: null });
|
||||
}
|
||||
|
||||
res.json({ ok: true, transcript: text, fields: parsed });
|
||||
} catch (err) {
|
||||
console.error('Voice fill error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// File fill — upload audio/video/text -> transcribe -> extract
|
||||
app.post('/api/file-fill', requireAuth, uploadLarge.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||
const currentFields = req.body.currentFields || null;
|
||||
const mime = req.file.mimetype;
|
||||
const name = req.file.originalname.toLowerCase();
|
||||
let transcript = '';
|
||||
|
||||
if (mime === 'text/plain' || name.endsWith('.txt')) {
|
||||
transcript = req.file.buffer.toString('utf-8');
|
||||
} else {
|
||||
console.log('Sending to Deepgram:', mime, req.file.size, 'bytes');
|
||||
transcript = await transcribeAudio(req.file.buffer, mime);
|
||||
}
|
||||
|
||||
if (!transcript.trim()) {
|
||||
return res.json({ ok: false, error: 'Не удалось получить текст из файла' });
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try { parsed = await extractFieldsFromText(transcript, currentFields); }
|
||||
catch (e) {
|
||||
console.error('Extract fields error:', e.message);
|
||||
return res.json({ ok: true, transcript: transcript.substring(0, 500), fields: null });
|
||||
}
|
||||
|
||||
res.json({ ok: true, transcript: transcript.substring(0, 500), fields: parsed });
|
||||
} catch (err) {
|
||||
console.error('File fill error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Random slug
|
||||
function randomSlug() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const part = (len) => Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
return `${part(3)}-${part(4)}-${part(3)}`;
|
||||
}
|
||||
|
||||
function isSlugFree(name) {
|
||||
return !fs.existsSync(path.join(SITES_DIR, name));
|
||||
}
|
||||
|
||||
app.get('/api/check-slug', requireAuth, (req, res) => {
|
||||
const name = (req.query.name || '').replace(/[^a-z0-9-]/gi, '').toLowerCase();
|
||||
if (!name) return res.json({ ok: true, free: false });
|
||||
res.json({ ok: true, free: isSlugFree(name), url: `summary.edemium.ru/${name}` });
|
||||
});
|
||||
|
||||
// Deploy — save HTML to /sites/summaries/{slug}/index.html
|
||||
app.post('/api/deploy', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { html } = req.body;
|
||||
if (!html) return res.status(400).json({ error: 'Missing html' });
|
||||
|
||||
let name = (req.body.slug || '').replace(/[^a-z0-9-]/gi, '').toLowerCase();
|
||||
if (!name) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
name = randomSlug();
|
||||
if (isSlugFree(name)) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSlugFree(name)) {
|
||||
return res.status(400).json({ error: `summary.edemium.ru/${name} уже занят` });
|
||||
}
|
||||
|
||||
const siteDir = path.join(SITES_DIR, name);
|
||||
fs.mkdirSync(siteDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(siteDir, 'index.html'), html, 'utf-8');
|
||||
|
||||
const url = `https://summary.edemium.ru/${name}`;
|
||||
res.json({ ok: true, url, name });
|
||||
} catch (err) {
|
||||
console.error('Deploy error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// List deployed summaries
|
||||
app.get('/api/summaries', requireAuth, (req, res) => {
|
||||
try {
|
||||
const dirs = fs.readdirSync(SITES_DIR)
|
||||
.filter(d => {
|
||||
const full = path.join(SITES_DIR, d);
|
||||
return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, 'index.html'));
|
||||
})
|
||||
.map(d => ({ name: d, url: `https://summary.edemium.ru/${d}` }));
|
||||
res.json({ ok: true, summaries: dirs });
|
||||
} catch (err) {
|
||||
res.json({ ok: true, summaries: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// Download as .docx
|
||||
app.post('/api/download-docx', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { html } = req.body;
|
||||
if (!html) return res.status(400).json({ error: 'No HTML' });
|
||||
|
||||
const HTMLtoDOCX = require('html-to-docx');
|
||||
const docxBuffer = await HTMLtoDOCX(html, null, {
|
||||
table: { row: { cantSplit: true } },
|
||||
footer: true,
|
||||
pageNumber: true,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="summary.docx"');
|
||||
res.send(Buffer.from(docxBuffer));
|
||||
} catch (err) {
|
||||
console.error('DOCX error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Telegram notification on generation
|
||||
function notifyTelegram(title, type) {
|
||||
if (!BOT_TOKEN || !CHAT_ID) return;
|
||||
const text = `📝 <b>Новое саммари</b>\n├ 📋 ${title}\n└ 🏷 ${type}`;
|
||||
fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chat_id: CHAT_ID, text, parse_mode: 'HTML' }),
|
||||
}).catch(e => console.error('TG notify error:', e.message));
|
||||
}
|
||||
|
||||
// Static files
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Serve published summaries
|
||||
app.get('/:slug', (req, res) => {
|
||||
const slug = req.params.slug.replace(/[^a-z0-9-]/gi, '').toLowerCase();
|
||||
const file = path.join(SITES_DIR, slug, 'index.html');
|
||||
if (fs.existsSync(file)) {
|
||||
res.sendFile(path.resolve(file));
|
||||
} else {
|
||||
res.status(404).send('Summary not found');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Summary Builder running on port ${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user