482 lines
20 KiB
JavaScript
482 lines
20 KiB
JavaScript
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}`);
|
||
});
|