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 = `📝 Новое саммари\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}`); });