From 085cc9a8371e0f4653a9243e558e6711c558a5bb Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Feb 2026 12:17:36 +0000 Subject: [PATCH] Initial commit: summary.edemium.ru - meeting summary generator --- .gitignore | 4 + Dockerfile | 7 + package.json | 15 + public/index.html | 964 ++++++++++++++++++++++++++++++++++++++++++ server.js | 481 +++++++++++++++++++++ template-meeting.html | 173 ++++++++ 6 files changed, 1644 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 package.json create mode 100644 public/index.html create mode 100644 server.js create mode 100644 template-meeting.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc6b769 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +*.log +sites/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2ef9ef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY . . +EXPOSE 3004 +CMD ["node", "server.js"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..360d9f1 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "summary-builder", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "express-session": "^1.17.3", + "multer": "^1.4.5-lts.1", + "html-to-docx": "^1.8.0", + "openai": "^4.52.0" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..954e518 --- /dev/null +++ b/public/index.html @@ -0,0 +1,964 @@ + + + + + +Summary Builder — Edemium + + + + + + + +
+
+ +
Summary Builder
+ + +
Неверный пароль
+
+
+ + +
+
+
Summary Builder
+
+ + +
+
+ summary.edemium.ru/ + + +
+ + +
+
+
+ +
+ +
+ + +
+ +
+
+ +
+
+
Расскажите голосом
+
Опишите встречу — ИИ заполнит все поля
+
+
0:00
+
+ + +
+
+ +
+
+
Загрузите запись
+
Аудио, видео или текст встречи
+
+
+ + + +
+
1 Встреча
+
Тема встречи
+ +
+
Дата
+ +
+
Участники
+ +
+
Тип встречи
+
+
Общая
+
Продажа
+
Брифинг
+
Интервью
+
Стратегия
+
+
+ + +
+
2 Транскрипт
+
Текст встречи (заполняется автоматически или вручную)
+ +
+
Контекст / Цели — опционально
+ +
+ + +
+ + +
+
+
+
+ + + +
+
+
+ +
+ + +
+
+ Превью саммари +
+ + +
+
+
+ +

Заполните данные слева и нажмите
Сгенерировать саммари

+

ИИ проанализирует встречу и создаст структурированное саммари с ключевыми решениями, действиями и цитатами

+
+ +
+
+
+ +
+ + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..783fc6e --- /dev/null +++ b/server.js @@ -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 = `📝 Новое саммари\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}`); +}); diff --git a/template-meeting.html b/template-meeting.html new file mode 100644 index 0000000..c8c4428 --- /dev/null +++ b/template-meeting.html @@ -0,0 +1,173 @@ + + + + + +Саммари встречи — {ТЕМА} + + + + + + +
+
САММАРИ ВСТРЕЧИ
+
+

{Тема встречи}

+
{Краткое описание — 1-2 предложения о чём была встреча}
+
+ Дата: {Дата}
+ Участники: {Список участников}
+ Тип: {Тип встречи} +
+
+ + +
+ +
+
Контекст встречи
+

{О чём встреча — 2-3 предложения с описанием цели, предыстории и ожиданий}

+
+ +
+
Ключевые темы
+
+ + + + +
ТемаСуть обсуждения
{Тема 1}{Что обсуждали}
{Тема 2}{Что обсуждали}
+
+
+ +
+
Решения
+
+ + + +
РешениеОтветственныйКомментарий
{Решение}{Кто}{Детали}
+
+
+ +
+
Действия
+
+ + + +
ЗадачаИсполнительСрок
{Задача}{Кто}{Когда}
+
+
+ +
+ + +
+ +
+
Возражения / Вопросы
+
+ + + +
Вопрос / ВозражениеКто озвучилКомментарий / Ответ
{Вопрос}{Кто}{Ответ}
+
+
+ +
+
Ключевые цитаты
+
+
"— {Цитата}"
+
{Кто сказал}
+
+
+ +
+
Следующие шаги
+
    +
  1. {Шаг 1}
  2. +
  3. {Шаг 2}
  4. +
  5. {Шаг 3}
  6. +
+
+ +
+
Рекомендации
+
    +
  • {Рекомендация 1}
  • +
  • {Рекомендация 2}
  • +
+
+ + +
+ + +