diff --git a/src/config/db.js b/src/config/db.js index c4eec93..b93d07f 100644 --- a/src/config/db.js +++ b/src/config/db.js @@ -10,57 +10,124 @@ pool.on('error', (err) => { const query = (text, params) => pool.query(text, params); const migrate = async () => { + // users — расширили под план/api_key/баланс await query(` - CREATE TABLE IF NOT EXISTS generation_jobs ( - id SERIAL PRIMARY KEY, - type VARCHAR(50) NOT NULL, -- 'article', 'post', 'caption' - channel_id INTEGER, - topic TEXT, - prompt TEXT, - result TEXT, - status VARCHAR(20) DEFAULT 'pending', -- pending, processing, done, failed - error TEXT, - metadata JSONB DEFAULT '{}', - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - ); - - CREATE TABLE IF NOT EXISTS channels ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL, - name VARCHAR(255) NOT NULL, - tg_channel_id VARCHAR(255), - bot_token TEXT, - topic TEXT, - tone VARCHAR(100) DEFAULT 'neutral', - language VARCHAR(10) DEFAULT 'ru', - post_schedule JSONB DEFAULT '{}', - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMPTZ DEFAULT NOW() - ); - CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password TEXT NOT NULL, - plan VARCHAR(20) DEFAULT 'free', -- free, pro, enterprise + name VARCHAR(255), + plan VARCHAR(20) DEFAULT 'free', api_key VARCHAR(64) UNIQUE, - created_at TIMESTAMPTZ DEFAULT NOW() - ); - - CREATE TABLE IF NOT EXISTS posts ( - id SERIAL PRIMARY KEY, - channel_id INTEGER REFERENCES channels(id), - job_id INTEGER REFERENCES generation_jobs(id), - content TEXT NOT NULL, - status VARCHAR(20) DEFAULT 'draft', -- draft, scheduled, published, failed - scheduled_at TIMESTAMPTZ, - published_at TIMESTAMPTZ, - tg_message_id BIGINT, - metadata JSONB DEFAULT '{}', + tokens_used BIGINT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() ); `); + + // channels — базовые настройки канала + await query(` + CREATE TABLE IF NOT EXISTS channels ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + tg_channel_id VARCHAR(255), + tg_username VARCHAR(255), + bot_token TEXT, + niche TEXT, -- узкая тематика + audience TEXT, -- описание ЦА + goal VARCHAR(50) DEFAULT 'educational', -- educational/news/entertainment/expert/sales + language VARCHAR(10) DEFAULT 'ru', + region VARCHAR(50) DEFAULT 'ru', -- ru/cis/west + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + + // channel_style — стилевые настройки (1:1 с channels) + await query(` + CREATE TABLE IF NOT EXISTS channel_style ( + channel_id INTEGER PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, + tone VARCHAR(50) DEFAULT 'friendly', -- friendly/serious/ironic/provocative/academic/custom + tone_custom TEXT, -- если tone='custom' + formality VARCHAR(20) DEFAULT 'informal', -- formal(вы)/informal(ты) + humor VARCHAR(20) DEFAULT 'moderate', -- none/dry/moderate/playful + post_length VARCHAR(20) DEFAULT 'medium', -- short(<300)/medium(300-800)/long(800-2000) + structure VARCHAR(50) DEFAULT 'mixed', -- plain/lists/headers/mixed + emoji_level VARCHAR(20) DEFAULT 'moderate', -- none/moderate/active + hashtags_mode VARCHAR(20) DEFAULT 'end', -- none/end/inline + cta_mode VARCHAR(20) DEFAULT 'sometimes', -- always/sometimes/never + example_posts JSONB DEFAULT '[]'::jsonb, -- массив строк-эталонов (few-shot) + banned_words JSONB DEFAULT '[]'::jsonb, -- стоп-слова + banned_topics JSONB DEFAULT '[]'::jsonb, -- запрещённые темы + expertise JSONB DEFAULT '[]'::jsonb, -- темы где автор силён + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + + // channel_schedule — расписание и рубрики + await query(` + CREATE TABLE IF NOT EXISTS channel_schedule ( + channel_id INTEGER PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, + posts_per_day INTEGER DEFAULT 1, + time_slots JSONB DEFAULT '[]'::jsonb, -- ["09:00","18:00"] + timezone VARCHAR(50) DEFAULT 'Europe/Moscow', + rubrics JSONB DEFAULT '[]'::jsonb, -- [{name,description,days:[1,3,5]}] + sources JSONB DEFAULT '[]'::jsonb, -- [{type:'rss',url:'...'},{type:'tg',channel:'@...'}] + auto_publish BOOLEAN DEFAULT false, -- авто-постинг без подтверждения + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + + // generation_jobs — задачи генерации + await query(` + CREATE TABLE IF NOT EXISTS generation_jobs ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + channel_id INTEGER REFERENCES channels(id) ON DELETE SET NULL, + type VARCHAR(50) NOT NULL, -- post/article/topics/rewrite + topic TEXT, + rubric VARCHAR(255), + prompt_debug TEXT, -- финальный промпт для отладки + result TEXT, + tokens_in INTEGER, + tokens_out INTEGER, + cost_cents INTEGER DEFAULT 0, + status VARCHAR(20) DEFAULT 'pending', + error TEXT, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + + // posts — готовые посты (черновики и опубликованные) + await query(` + CREATE TABLE IF NOT EXISTS posts ( + id SERIAL PRIMARY KEY, + channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, + job_id INTEGER REFERENCES generation_jobs(id) ON DELETE SET NULL, + content TEXT NOT NULL, + image_url TEXT, + status VARCHAR(20) DEFAULT 'draft', -- draft/scheduled/published/failed + scheduled_at TIMESTAMPTZ, + published_at TIMESTAMPTZ, + tg_message_id BIGINT, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + + // индексы + await query(` + CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id); + CREATE INDEX IF NOT EXISTS idx_posts_channel ON posts(channel_id); + CREATE INDEX IF NOT EXISTS idx_posts_scheduled ON posts(scheduled_at) WHERE status='scheduled'; + CREATE INDEX IF NOT EXISTS idx_jobs_status ON generation_jobs(status); + CREATE INDEX IF NOT EXISTS idx_jobs_user ON generation_jobs(user_id); + `); + console.log('[DB] Migrations applied'); }; diff --git a/src/routes/channels.js b/src/routes/channels.js index f22208e..cb65c1e 100644 --- a/src/routes/channels.js +++ b/src/routes/channels.js @@ -1,50 +1,73 @@ const express = require('express'); const router = express.Router(); -const { query } = require('../config/db'); +const channelsSvc = require('../services/channels'); -// GET /api/channels - list channels for user +const getUserId = (req) => { + const id = req.headers['x-user-id']; + if (!id) return null; + return parseInt(id); +}; + +// GET /api/channels — список каналов пользователя router.get('/', async (req, res) => { - const userId = req.headers['x-user-id']; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - const { rows } = await query(`SELECT * FROM channels WHERE user_id=$1 ORDER BY created_at DESC`, [userId]); - res.json(rows); + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + try { + const channels = await channelsSvc.listChannels(userId); + res.json(channels); + } catch (err) { + res.status(500).json({ error: err.message }); + } }); -// POST /api/channels - create channel +// GET /api/channels/:id — один канал со всеми настройками +router.get('/:id', async (req, res) => { + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + try { + const channel = await channelsSvc.getFullChannel(req.params.id, userId); + if (!channel) return res.status(404).json({ error: 'Channel not found' }); + res.json(channel); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/channels — создать канал router.post('/', async (req, res) => { - const userId = req.headers['x-user-id']; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - const { name, tgChannelId, botToken, topic, tone, language } = req.body; - if (!name) return res.status(400).json({ error: 'name is required' }); - const { rows } = await query( - `INSERT INTO channels (user_id, name, tg_channel_id, bot_token, topic, tone, language) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, - [userId, name, tgChannelId || null, botToken || null, topic || '', tone || 'neutral', language || 'ru'] - ); - res.json(rows[0]); + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + try { + const channel = await channelsSvc.createChannel(userId, req.body); + res.json(channel); + } catch (err) { + console.error('[Route] POST /channels', err); + res.status(500).json({ error: err.message }); + } }); -// PATCH /api/channels/:id - update channel +// PATCH /api/channels/:id — обновить router.patch('/:id', async (req, res) => { - const userId = req.headers['x-user-id']; - const { name, topic, tone, language, botToken, tgChannelId, isActive, postSchedule } = req.body; - const { rows } = await query( - `UPDATE channels SET - name=COALESCE($1,name), topic=COALESCE($2,topic), tone=COALESCE($3,tone), - language=COALESCE($4,language), bot_token=COALESCE($5,bot_token), - tg_channel_id=COALESCE($6,tg_channel_id), is_active=COALESCE($7,is_active), - post_schedule=COALESCE($8,post_schedule) - WHERE id=$9 AND user_id=$10 RETURNING *`, - [name, topic, tone, language, botToken, tgChannelId, isActive, postSchedule ? JSON.stringify(postSchedule) : null, req.params.id, userId] - ); - if (!rows.length) return res.status(404).json({ error: 'Channel not found' }); - res.json(rows[0]); + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + try { + const channel = await channelsSvc.updateChannel(req.params.id, userId, req.body); + res.json(channel); + } catch (err) { + res.status(500).json({ error: err.message }); + } }); // DELETE /api/channels/:id router.delete('/:id', async (req, res) => { - const userId = req.headers['x-user-id']; - await query(`DELETE FROM channels WHERE id=$1 AND user_id=$2`, [req.params.id, userId]); - res.json({ ok: true }); + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + try { + await channelsSvc.deleteChannel(req.params.id, userId); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } }); module.exports = router; diff --git a/src/routes/generate.js b/src/routes/generate.js index c61f3a7..d0c0397 100644 --- a/src/routes/generate.js +++ b/src/routes/generate.js @@ -1,23 +1,28 @@ const express = require('express'); const router = express.Router(); const { query } = require('../config/db'); +const channelsSvc = require('../services/channels'); const generationQueue = require('../workers/generation'); -// POST /api/generate - create generation job +// POST /api/generate — создать задачу генерации router.post('/', async (req, res) => { try { - const { type, topic, tone = 'neutral', language = 'ru', channelContext = '', keywords = [], channelId } = req.body; + const { type, topic, channelId, rubric, keywords = [], useCritique = true } = req.body; + const userId = req.headers['x-user-id'] || null; - if (!type || !topic) return res.status(400).json({ error: 'type and topic are required' }); + if (!type) return res.status(400).json({ error: 'type is required' }); if (!['post', 'article', 'topics'].includes(type)) return res.status(400).json({ error: 'Invalid type' }); + if (type !== 'topics' && !topic) return res.status(400).json({ error: 'topic is required' }); + if (type === 'post' && !channelId) return res.status(400).json({ error: 'channelId is required for posts' }); const { rows } = await query( - `INSERT INTO generation_jobs (type, channel_id, topic, status) VALUES ($1,$2,$3,'pending') RETURNING id`, - [type, channelId || null, topic] + `INSERT INTO generation_jobs (user_id, channel_id, type, topic, rubric, status) + VALUES ($1,$2,$3,$4,$5,'pending') RETURNING id`, + [userId, channelId || null, type, topic || null, rubric || null] ); const jobId = rows[0].id; - await generationQueue.add({ jobId, type, topic, tone, language, channelContext, keywords }); + await generationQueue.add({ jobId, type, topic, channelId, rubric, keywords, useCritique }); res.json({ jobId, status: 'pending' }); } catch (err) { @@ -26,7 +31,7 @@ router.post('/', async (req, res) => { } }); -// GET /api/generate/:id - get job status +// GET /api/generate/:id — статус и результат router.get('/:id', async (req, res) => { try { const { rows } = await query(`SELECT * FROM generation_jobs WHERE id=$1`, [req.params.id]); diff --git a/src/services/ai.js b/src/services/ai.js index dd466c6..c5f6729 100644 --- a/src/services/ai.js +++ b/src/services/ai.js @@ -1,45 +1,47 @@ const axios = require('axios'); const config = require('../config'); +const pb = require('./promptBuilder'); const client = axios.create({ baseURL: config.ai.baseUrl, headers: { 'Content-Type': 'application/json' }, - timeout: 120000, + timeout: 180000, }); /** - * Low-level chat completion call (OpenAI-compatible gateway) - * @param {string} model - * @param {string} systemPrompt - * @param {string} userPrompt - * @param {object} options - { maxTokens } + * Низкоуровневый вызов chat completion. + * @returns {object} { text, usage } */ -const chat = async (model, systemPrompt, userPrompt, options = {}) => { - const { maxTokens = 2000 } = options; +async function chat(model, systemPrompt, userPrompt, options = {}) { + const { maxTokens = 2000, temperature } = options; - const res = await client.post('/chat/completions', { + const body = { model, max_tokens: maxTokens, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], - }, { + }; + if (temperature !== undefined) body.temperature = temperature; + + const res = await client.post('/chat/completions', body, { headers: { Authorization: `Bearer ${config.ai.apiKey}` }, }); const text = res.data.choices?.[0]?.message?.content; if (!text) throw new Error('Empty response from AI gateway'); - return text.trim(); -}; + + return { + text: text.trim(), + usage: res.data.usage || {}, + }; +} /** - * Generate an image (GPT/DALL-E via gateway, separate key) - * @param {string} prompt - * @param {object} options - { size } - * @returns {string} image URL or b64 + * Генерация изображения. */ -const image = async (prompt, options = {}) => { +async function image(prompt, options = {}) { const { size = '1024x1024' } = options; const res = await client.post('/images/generations', { model: config.ai.models.image, @@ -50,52 +52,107 @@ const image = async (prompt, options = {}) => { headers: { Authorization: `Bearer ${config.ai.imageApiKey}` }, }); return res.data.data?.[0]?.url || res.data.data?.[0]?.b64_json; -}; +} /** - * Generate a Telegram post + * Сгенерировать пост для канала. + * Принимает развёрнутый channel (со style/schedule) и опции. + * @param {object} channel - {name, niche, audience, goal, language, region, style:{...}} + * @param {object} opts - {topic, rubric, useCritique, returnPrompt} + * @returns {object} {content, prompt, usage} */ -const generatePost = async ({ topic, tone = 'neutral', language = 'ru', channelContext = '' }) => { - const system = `Ты — профессиональный автор Telegram-каналов. Пишешь посты на ${language === 'ru' ? 'русском' : 'английском'} языке. -Тон: ${tone}. -${channelContext ? `Контекст канала: ${channelContext}` : ''} -Правила: -- Пост 150-400 слов -- Используй эмодзи уместно -- Не используй markdown заголовки (##, **), только текст и эмодзи -- В конце добавь 2-4 релевантных хэштега`; +async function generatePost(channel, opts = {}) { + const { topic, rubric = '', useCritique = true, returnPrompt = false } = opts; + if (!topic) throw new Error('topic is required'); - const user = `Напиши пост на тему: "${topic}"`; - return chat(config.ai.models.post, system, user, { maxTokens: 1000 }); -}; + const systemPrompt = pb.buildPostSystemPrompt(channel, rubric); + const userPrompt = `Напиши пост на тему: "${topic}"`; -/** - * Generate a blog article - */ -const generateArticle = async ({ topic, language = 'ru', keywords = [] }) => { - const system = `Ты — эксперт по искусственному интеллекту, пишешь SEO-оптимизированные статьи на ${language === 'ru' ? 'русском' : 'английском'} языке. -Правила: -- Статья 800-1500 слов -- Структура: заголовок H1, введение, 3-5 разделов H2, заключение -- Используй ключевые слова органично -- Простой и понятный язык`; + // Шаг 1: первичная генерация (с повышенной температурой для разнообразия) + const first = await chat( + config.ai.models.post, + systemPrompt, + userPrompt, + { maxTokens: 1200, temperature: 0.9 } + ); - const user = `Напиши статью на тему: "${topic}"${keywords.length ? `\nКлючевые слова: ${keywords.join(', ')}` : ''}`; - return chat(config.ai.models.article, system, user, { maxTokens: 3000 }); -}; + let finalText = first.text; + let totalUsage = { ...first.usage }; -/** - * Generate topic ideas for a channel - */ -const generateTopics = async ({ channelContext, count = 10, language = 'ru' }) => { - const system = `Генерируй идеи для постов в Telegram-канале. Отвечай только JSON массивом строк, без пояснений.`; - const user = `Придумай ${count} идей для постов. Контекст канала: "${channelContext}". Язык: ${language}. Формат: ["тема1","тема2",...]`; - const raw = await chat(config.ai.models.topics, system, user, { maxTokens: 800 }); - try { - return JSON.parse(raw.replace(/```json|```/g, '').trim()); - } catch { - return raw.split('\n').map(s => s.replace(/^[-*\d.\s]+/, '').trim()).filter(Boolean).slice(0, count); + // Шаг 2: self-critique (опционально, дороже но качественнее) + if (useCritique) { + try { + const critiquePrompt = pb.buildCritiquePrompt(first.text, channel); + const critiqued = await chat( + config.ai.models.post, + 'Ты редактор, который убирает AI-следы из текстов.', + critiquePrompt, + { maxTokens: 1200, temperature: 0.7 } + ); + finalText = critiqued.text; + totalUsage.prompt_tokens = (totalUsage.prompt_tokens || 0) + (critiqued.usage.prompt_tokens || 0); + totalUsage.completion_tokens = (totalUsage.completion_tokens || 0) + (critiqued.usage.completion_tokens || 0); + } catch (err) { + // если critique упал — используем первый результат + console.warn('[AI] Critique step failed, using original:', err.message); + } } -}; -module.exports = { chat, image, generatePost, generateArticle, generateTopics }; + return { + content: finalText, + prompt: returnPrompt ? systemPrompt : undefined, + usage: totalUsage, + }; +} + +/** + * Сгенерировать идеи тем для канала. + */ +async function generateTopics(channel, count = 5) { + const systemPrompt = `Генерируй идеи для постов. Отвечай ТОЛЬКО JSON-массивом строк, без пояснений и markdown.`; + const userPrompt = pb.buildTopicsPrompt(channel, count); + + const res = await chat( + config.ai.models.topics, + systemPrompt, + userPrompt, + { maxTokens: 800, temperature: 1.0 } + ); + + let topics; + try { + topics = JSON.parse(res.text.replace(/```json|```/g, '').trim()); + } catch { + topics = res.text + .split('\n') + .map(s => s.replace(/^[-*\d.)\s"]+/, '').replace(/[",]+$/, '').trim()) + .filter(Boolean) + .slice(0, count); + } + return { topics, usage: res.usage }; +} + +/** + * Сгенерировать статью для блога. + */ +async function generateArticle(channel, opts = {}) { + const { topic, keywords = [] } = opts; + if (!topic) throw new Error('topic is required'); + const systemPrompt = pb.buildArticleSystemPrompt(channel, keywords); + const userPrompt = `Напиши статью на тему: "${topic}"`; + const res = await chat( + config.ai.models.article, + systemPrompt, + userPrompt, + { maxTokens: 4000, temperature: 0.8 } + ); + return { content: res.text, usage: res.usage }; +} + +module.exports = { + chat, + image, + generatePost, + generateTopics, + generateArticle, +}; diff --git a/src/services/channels.js b/src/services/channels.js new file mode 100644 index 0000000..059c316 --- /dev/null +++ b/src/services/channels.js @@ -0,0 +1,190 @@ +const { query } = require('../config/db'); + +/** + * Получить канал со всеми связанными настройками (style + schedule). + */ +async function getFullChannel(channelId, userId = null) { + const filter = userId ? `AND user_id=$2` : ''; + const params = userId ? [channelId, userId] : [channelId]; + + const { rows } = await query( + `SELECT c.*, + to_jsonb(s.*) - 'channel_id' - 'updated_at' AS style, + to_jsonb(sch.*) - 'channel_id' - 'updated_at' AS schedule + FROM channels c + LEFT JOIN channel_style s ON s.channel_id = c.id + LEFT JOIN channel_schedule sch ON sch.channel_id = c.id + WHERE c.id=$1 ${filter}`, + params + ); + return rows[0] || null; +} + +async function listChannels(userId) { + const { rows } = await query( + `SELECT c.*, + to_jsonb(s.*) - 'channel_id' - 'updated_at' AS style, + to_jsonb(sch.*) - 'channel_id' - 'updated_at' AS schedule + FROM channels c + LEFT JOIN channel_style s ON s.channel_id = c.id + LEFT JOIN channel_schedule sch ON sch.channel_id = c.id + WHERE c.user_id=$1 + ORDER BY c.created_at DESC`, + [userId] + ); + return rows; +} + +/** + * Создать канал — заполняет 3 таблицы транзакционно. + */ +async function createChannel(userId, data) { + const { + name, tg_channel_id, tg_username, bot_token, + niche, audience, goal, language, region, + style = {}, schedule = {}, + } = data; + + if (!name) throw new Error('name is required'); + + const client = await require('../config/db').query; + + // INSERT channel + const { rows: chRows } = await query( + `INSERT INTO channels + (user_id, name, tg_channel_id, tg_username, bot_token, niche, audience, goal, language, region) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING *`, + [ + userId, name, tg_channel_id || null, tg_username || null, bot_token || null, + niche || null, audience || null, goal || 'educational', + language || 'ru', region || 'ru', + ] + ); + const channel = chRows[0]; + + // INSERT style + await query( + `INSERT INTO channel_style + (channel_id, tone, tone_custom, formality, humor, post_length, structure, + emoji_level, hashtags_mode, cta_mode, example_posts, banned_words, banned_topics, expertise) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + [ + channel.id, + style.tone || 'friendly', + style.tone_custom || null, + style.formality || 'informal', + style.humor || 'moderate', + style.post_length || 'medium', + style.structure || 'mixed', + style.emoji_level || 'moderate', + style.hashtags_mode || 'end', + style.cta_mode || 'sometimes', + JSON.stringify(style.example_posts || []), + JSON.stringify(style.banned_words || []), + JSON.stringify(style.banned_topics || []), + JSON.stringify(style.expertise || []), + ] + ); + + // INSERT schedule + await query( + `INSERT INTO channel_schedule + (channel_id, posts_per_day, time_slots, timezone, rubrics, sources, auto_publish) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [ + channel.id, + schedule.posts_per_day || 1, + JSON.stringify(schedule.time_slots || []), + schedule.timezone || 'Europe/Moscow', + JSON.stringify(schedule.rubrics || []), + JSON.stringify(schedule.sources || []), + schedule.auto_publish || false, + ] + ); + + return getFullChannel(channel.id); +} + +/** + * Обновить канал (только то что передано). + */ +async function updateChannel(channelId, userId, data) { + const { style, schedule, ...channelFields } = data; + + // обновить channels + if (Object.keys(channelFields).length) { + const fields = ['name', 'tg_channel_id', 'tg_username', 'bot_token', + 'niche', 'audience', 'goal', 'language', 'region', 'is_active']; + const updates = fields.filter(f => channelFields[f] !== undefined); + if (updates.length) { + const setClauses = updates.map((f, i) => `${f}=$${i + 1}`).join(', '); + const values = updates.map(f => channelFields[f]); + values.push(channelId, userId); + await query( + `UPDATE channels SET ${setClauses}, updated_at=NOW() + WHERE id=$${values.length - 1} AND user_id=$${values.length}`, + values + ); + } + } + + // обновить style + if (style && Object.keys(style).length) { + const fields = ['tone', 'tone_custom', 'formality', 'humor', 'post_length', + 'structure', 'emoji_level', 'hashtags_mode', 'cta_mode', + 'example_posts', 'banned_words', 'banned_topics', 'expertise']; + const updates = fields.filter(f => style[f] !== undefined); + if (updates.length) { + const setClauses = updates.map((f, i) => { + const isJson = ['example_posts', 'banned_words', 'banned_topics', 'expertise'].includes(f); + return `${f}=$${i + 1}${isJson ? '::jsonb' : ''}`; + }).join(', '); + const values = updates.map(f => { + const isJson = ['example_posts', 'banned_words', 'banned_topics', 'expertise'].includes(f); + return isJson ? JSON.stringify(style[f]) : style[f]; + }); + values.push(channelId); + await query( + `UPDATE channel_style SET ${setClauses}, updated_at=NOW() WHERE channel_id=$${values.length}`, + values + ); + } + } + + // обновить schedule + if (schedule && Object.keys(schedule).length) { + const fields = ['posts_per_day', 'time_slots', 'timezone', 'rubrics', 'sources', 'auto_publish']; + const updates = fields.filter(f => schedule[f] !== undefined); + if (updates.length) { + const setClauses = updates.map((f, i) => { + const isJson = ['time_slots', 'rubrics', 'sources'].includes(f); + return `${f}=$${i + 1}${isJson ? '::jsonb' : ''}`; + }).join(', '); + const values = updates.map(f => { + const isJson = ['time_slots', 'rubrics', 'sources'].includes(f); + return isJson ? JSON.stringify(schedule[f]) : schedule[f]; + }); + values.push(channelId); + await query( + `UPDATE channel_schedule SET ${setClauses}, updated_at=NOW() WHERE channel_id=$${values.length}`, + values + ); + } + } + + return getFullChannel(channelId, userId); +} + +async function deleteChannel(channelId, userId) { + await query(`DELETE FROM channels WHERE id=$1 AND user_id=$2`, [channelId, userId]); + return { ok: true }; +} + +module.exports = { + getFullChannel, + listChannels, + createChannel, + updateChannel, + deleteChannel, +}; diff --git a/src/services/promptBuilder.js b/src/services/promptBuilder.js new file mode 100644 index 0000000..dc81511 --- /dev/null +++ b/src/services/promptBuilder.js @@ -0,0 +1,221 @@ +/** + * Prompt Builder — сборка системного промпта из настроек канала. + * Логика "человечности" живёт здесь, отдельно от вызовов AI. + */ + +// Базовые правила, чтобы текст не пах AI +const HUMANITY_RULES = ` +ПРАВИЛА ЖИВОГО ТЕКСТА (соблюдай строго): + +1. Варьируй длину предложений. Короткие. И длинные, развёрнутые, с уточнениями и придаточными. Иногда — одно слово. + +2. Никогда не используй эти AI-маркеры: + - "В современном мире...", "В наше время..." + - "Важно отметить, что...", "Стоит подчеркнуть..." + - "Революционный", "Уникальный", "Поистине", "По-настоящему" + - "В заключение хотелось бы сказать..." + - "Не секрет, что..." + - Двоеточие после каждого второго утверждения + - Идеально симметричные списки на любой вопрос + +3. Конкретика вместо абстракций: + - Не "многие компании" — а "Сбер, Яндекс, Тинькофф" + - Не "недавно" — а "на прошлой неделе" или "в марте" + - Не "большие цифры" — а "$2.3 млрд" или "37 тысяч пользователей" + - Имена, цифры, примеры — всегда лучше обобщений + +4. Личный голос. Можно (и нужно): + - Начинать предложение с "И", "Но", "А" + - Использовать "я думаю", "мне кажется", "честно говоря" + - Высказать мнение, не быть нейтральным + - Разговорные обороты, если это подходит стилю + +5. Шероховатости — это хорошо: + - Не идеальный текст, а живой + - Можно оборвать мысль и пойти в сторону + - Можно вернуться к началу неожиданно + +6. Не объясняй очевидное. Доверяй читателю. + +7. Не заканчивай каждый пост призывом "Подписывайтесь!" или вопросом "А что думаете вы?" — только если это органично. +`.trim(); + +const TONE_HINTS = { + friendly: 'Дружелюбный, тёплый. Как пишет хороший знакомый.', + serious: 'Серьёзный, без шуток. Деловой, но не сухой.', + ironic: 'С иронией, лёгким сарказмом. Подмечает абсурд.', + provocative: 'Провокационный, спорный. Не боится острых углов.', + academic: 'Академичный, с терминологией. Глубокий разбор.', +}; + +const LENGTH_HINTS = { + short: '150-300 знаков. Только суть.', + medium: '300-800 знаков. Развёрнутая мысль, но без воды.', + long: '800-2000 знаков. Лонгрид с подразделами или развитой мыслью.', +}; + +const EMOJI_HINTS = { + none: 'Эмодзи не используй вообще.', + moderate: 'Эмодзи используй умеренно — 1-3 на пост, для акцента.', + active: 'Эмодзи используй активно, но к месту. 5-10 на пост ок.', +}; + +const HASHTAG_HINTS = { + none: 'Хэштеги не добавляй.', + end: 'В конце поста — 2-4 релевантных хэштега.', + inline: 'Хэштеги вплети в текст естественно (1-3 штуки).', +}; + +const STRUCTURE_HINTS = { + plain: 'Сплошной текст без списков и заголовков.', + lists: 'Используй списки (•, цифры) где это уместно.', + headers: 'Можешь использовать подзаголовки для разделов.', + mixed: 'Структура по ситуации — где-то списки, где-то сплошной текст.', +}; + +const HUMOR_HINTS = { + none: 'Без юмора.', + dry: 'Сухой юмор, ирония, сарказм — изредка.', + moderate: 'Юмор умеренный, по ситуации.', + playful: 'Можно шутить, использовать мемные обороты.', +}; + +const GOAL_HINTS = { + educational: 'Цель — научить, объяснить. Подача от простого к сложному.', + news: 'Цель — рассказать о новостях. Факты + краткий комментарий.', + entertainment: 'Цель — развлечь. Можно подавать материал легко и игриво.', + expert: 'Цель — показать экспертизу. Глубина, нюансы, инсайты.', + sales: 'Цель — продать или подвести к действию. Польза → ценность → CTA.', +}; + +/** + * Собирает системный промпт для генерации поста. + * @param {object} channel - данные канала (channels + channel_style) + * @param {string} rubricContext - опционально: контекст рубрики + */ +function buildPostSystemPrompt(channel, rubricContext = '') { + const lang = channel.language === 'en' ? 'английском' : 'русском'; + const style = channel.style || {}; + + const tone = style.tone === 'custom' && style.tone_custom + ? style.tone_custom + : TONE_HINTS[style.tone] || TONE_HINTS.friendly; + + const parts = [ + `Ты автор Telegram-канала "${channel.name}". Пишешь на ${lang} языке.`, + '', + 'КАНАЛ:', + `• Ниша: ${channel.niche || 'не указана'}`, + `• Аудитория: ${channel.audience || 'широкая'}`, + `• Цель канала: ${GOAL_HINTS[channel.goal] || GOAL_HINTS.educational}`, + channel.region ? `• Регион/контекст: ${channel.region}` : null, + '', + 'СТИЛЬ:', + `• Тон: ${tone}`, + `• Обращение: ${style.formality === 'formal' ? 'на "вы", уважительно' : 'на "ты", по-простому'}`, + `• Юмор: ${HUMOR_HINTS[style.humor] || HUMOR_HINTS.moderate}`, + `• Длина: ${LENGTH_HINTS[style.post_length] || LENGTH_HINTS.medium}`, + `• Структура: ${STRUCTURE_HINTS[style.structure] || STRUCTURE_HINTS.mixed}`, + `• Эмодзи: ${EMOJI_HINTS[style.emoji_level] || EMOJI_HINTS.moderate}`, + `• Хэштеги: ${HASHTAG_HINTS[style.hashtags_mode] || HASHTAG_HINTS.end}`, + ].filter(Boolean); + + // Запрещённые слова и темы + if (style.banned_words?.length) { + parts.push('', `ЗАПРЕЩЕНО упоминать слова: ${style.banned_words.join(', ')}`); + } + if (style.banned_topics?.length) { + parts.push(`ЗАПРЕЩЕНО затрагивать темы: ${style.banned_topics.join(', ')}`); + } + + // Рубрика + if (rubricContext) { + parts.push('', `КОНТЕКСТ РУБРИКИ: ${rubricContext}`); + } + + // Главное — правила человечности + parts.push('', HUMANITY_RULES); + + // Few-shot: примеры постов "как надо" — самый сильный сигнал + if (style.example_posts?.length) { + parts.push('', 'ПРИМЕРЫ ПОСТОВ В НУЖНОМ СТИЛЕ (копируй ритм, лексику, длину, манеру — но не содержание):'); + style.example_posts.slice(0, 3).forEach((ex, i) => { + parts.push('', `--- Пример ${i + 1} ---`, ex.trim()); + }); + parts.push('--- конец примеров ---'); + } + + return parts.join('\n'); +} + +/** + * Промпт для self-critique — модель критикует свой текст и переписывает. + */ +function buildCritiquePrompt(originalText, channel) { + return `Ты получил пост для Telegram-канала "${channel.name}". Твоя задача: + +1. Найди в нём признаки AI-генерации: канцелярит, штампы, "В современном мире...", избыточную симметрию, отсутствие конкретики, безличный тон. +2. Найди места, где текст звучит "слишком гладко" или "слишком правильно". +3. Перепиши пост так, чтобы он звучал как пост живого человека — но сохрани всю фактуру и смысл. + +Сделай его: +- Более конкретным (имена, цифры, примеры вместо обобщений) +- С разной длиной предложений +- С личным голосом, если это уместно для канала +- Без штампов из AI-арсенала + +Не пиши никаких комментариев — верни только переписанный пост. + +ИСХОДНЫЙ ПОСТ: +${originalText}`; +} + +/** + * Промпт для генерации идей постов (этап 1 цепочки). + */ +function buildTopicsPrompt(channel, count = 5) { + const style = channel.style || {}; + return `Ты автор канала "${channel.name}" (ниша: ${channel.niche || 'общая'}, аудитория: ${channel.audience || 'широкая'}). +Цель: ${GOAL_HINTS[channel.goal] || GOAL_HINTS.educational} + +Придумай ${count} конкретных, небанальных тем для постов. Не общие категории, а готовые угловые заходы. + +Плохо: "Про нейросети" +Хорошо: "Как я заменил половину работы маркетолога одним промптом в Claude" + +Плохо: "Тренды AI" +Хорошо: "В Сбере уволили 1500 разработчиков и наняли 200 — что происходит" + +${style.banned_topics?.length ? `НЕ трогай темы: ${style.banned_topics.join(', ')}` : ''} + +Верни JSON-массив строк, без пояснений: ["тема1", "тема2", ...]`; +} + +/** + * Промпт для генерации статьи (для сайта zeropost.ru). + */ +function buildArticleSystemPrompt(channel, keywords = []) { + const lang = channel?.language === 'en' ? 'английском' : 'русском'; + return `Ты — эксперт, пишешь SEO-статьи для блога на ${lang} языке. + +Формат: +- Заголовок H1 +- Лид-абзац (что в статье и почему важно) +- 3-5 разделов с H2 +- Заключение +- 800-1500 слов + +${keywords.length ? `Ключевые слова (вплети органично): ${keywords.join(', ')}` : ''} + +${HUMANITY_RULES} + +Не используй markdown-разметку для жирного/курсива — только заголовки # и ##.`; +} + +module.exports = { + buildPostSystemPrompt, + buildCritiquePrompt, + buildTopicsPrompt, + buildArticleSystemPrompt, + HUMANITY_RULES, +}; diff --git a/src/workers/generation.js b/src/workers/generation.js index 20425e6..ac2b641 100644 --- a/src/workers/generation.js +++ b/src/workers/generation.js @@ -1,12 +1,13 @@ const Queue = require('bull'); const config = require('../config'); const ai = require('../services/ai'); +const channelsSvc = require('../services/channels'); const { query } = require('../config/db'); const generationQueue = new Queue('generation', { redis: config.redis, defaultJobOptions: { - attempts: 3, + attempts: 2, backoff: { type: 'exponential', delay: 5000 }, removeOnComplete: 100, removeOnFail: 200, @@ -14,42 +15,60 @@ const generationQueue = new Queue('generation', { }); generationQueue.process(async (job) => { - const { jobId, type, topic, tone, language, channelContext, keywords } = job.data; + const { jobId, type, topic, channelId, rubric, keywords, useCritique } = job.data; await query(`UPDATE generation_jobs SET status='processing', updated_at=NOW() WHERE id=$1`, [jobId]); try { - let result; + let resultText; + let usage = {}; + + // загружаем канал если есть channelId + const channel = channelId ? await channelsSvc.getFullChannel(channelId) : null; + if (type === 'post') { - result = await ai.generatePost({ topic, tone, language, channelContext }); + if (!channel) throw new Error('Channel not found for post generation'); + const r = await ai.generatePost(channel, { topic, rubric, useCritique }); + resultText = r.content; + usage = r.usage; + } else if (type === 'article') { - result = await ai.generateArticle({ topic, language, keywords }); + const r = await ai.generateArticle(channel || { language: 'ru' }, { topic, keywords }); + resultText = r.content; + usage = r.usage; + } else if (type === 'topics') { - const topics = await ai.generateTopics({ channelContext: topic, language }); - result = JSON.stringify(topics); + if (!channel) throw new Error('Channel required for topic generation'); + const r = await ai.generateTopics(channel, 10); + resultText = JSON.stringify(r.topics); + usage = r.usage; + } else { throw new Error(`Unknown job type: ${type}`); } await query( - `UPDATE generation_jobs SET status='done', result=$1, updated_at=NOW() WHERE id=$2`, - [result, jobId] + `UPDATE generation_jobs + SET status='done', result=$1, tokens_in=$2, tokens_out=$3, updated_at=NOW() + WHERE id=$4`, + [resultText, usage.prompt_tokens || null, usage.completion_tokens || null, jobId] ); - console.log(`[Worker] Job ${jobId} (${type}) done`); - return { jobId, result }; + console.log(`[Worker] Job ${jobId} (${type}) done. Tokens: ${usage.prompt_tokens}/${usage.completion_tokens}`); + return { jobId, ok: true }; } catch (err) { await query( `UPDATE generation_jobs SET status='failed', error=$1, updated_at=NOW() WHERE id=$2`, [err.message, jobId] ); + console.error(`[Worker] Job ${jobId} failed:`, err.message); throw err; } }); generationQueue.on('failed', (job, err) => { - console.error(`[Worker] Job ${job.data.jobId} failed:`, err.message); + console.error(`[Worker] Bull failed handler:`, err.message); }); console.log('[Worker] Generation queue started');