Files
summary-builder/server.js

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