Initial commit: summary.edemium.ru - meeting summary generator

This commit is contained in:
root
2026-02-12 12:17:36 +00:00
commit 085cc9a837
6 changed files with 1644 additions and 0 deletions

481
server.js Normal file
View 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}`);
});