forked from admin/zeropost-engine
feat: расширенная анкета канала + промпт-инжиниринг для человечности
БД (новые таблицы): - channel_style: тон/юмор/длина/структура/эмодзи/хэштеги/примеры постов/стоп-слова - channel_schedule: расписание, рубрики, источники, auto_publish - generation_jobs: добавлены user_id, tokens, cost, prompt_debug - posts: связка с job, image_url, scheduling Новый модуль services/promptBuilder.js: - HUMANITY_RULES: правила живого текста (антипаттерны, личный голос, конкретика) - buildPostSystemPrompt: собирает промпт из канала + few-shot примеров - buildCritiquePrompt: self-critique для очистки от AI-следов services/ai.js: - generatePost теперь использует 2-step chain: генерация + critique - temperature настроен (0.9 для разнообразия) - возвращает usage/токены services/channels.js: новый сервис, работа с тремя таблицами транзакционно routes/channels.js: CRUD под расширенную модель routes/generate.js: связка с channelId, передача в worker Результат на тестах: пост следует стилю few-shot примеров, без AI-маркеров
This commit is contained in:
+109
-42
@@ -10,57 +10,124 @@ pool.on('error', (err) => {
|
|||||||
const query = (text, params) => pool.query(text, params);
|
const query = (text, params) => pool.query(text, params);
|
||||||
|
|
||||||
const migrate = async () => {
|
const migrate = async () => {
|
||||||
|
// users — расширили под план/api_key/баланс
|
||||||
await query(`
|
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 (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
password TEXT 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,
|
api_key VARCHAR(64) UNIQUE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
tokens_used BIGINT DEFAULT 0,
|
||||||
);
|
|
||||||
|
|
||||||
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 '{}',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
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');
|
console.log('[DB] Migrations applied');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+55
-32
@@ -1,50 +1,73 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
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) => {
|
router.get('/', async (req, res) => {
|
||||||
const userId = req.headers['x-user-id'];
|
const userId = getUserId(req);
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||||
const { rows } = await query(`SELECT * FROM channels WHERE user_id=$1 ORDER BY created_at DESC`, [userId]);
|
try {
|
||||||
res.json(rows);
|
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) => {
|
router.post('/', async (req, res) => {
|
||||||
const userId = req.headers['x-user-id'];
|
const userId = getUserId(req);
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||||
const { name, tgChannelId, botToken, topic, tone, language } = req.body;
|
try {
|
||||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
const channel = await channelsSvc.createChannel(userId, req.body);
|
||||||
const { rows } = await query(
|
res.json(channel);
|
||||||
`INSERT INTO channels (user_id, name, tg_channel_id, bot_token, topic, tone, language) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
} catch (err) {
|
||||||
[userId, name, tgChannelId || null, botToken || null, topic || '', tone || 'neutral', language || 'ru']
|
console.error('[Route] POST /channels', err);
|
||||||
);
|
res.status(500).json({ error: err.message });
|
||||||
res.json(rows[0]);
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/channels/:id - update channel
|
// PATCH /api/channels/:id — обновить
|
||||||
router.patch('/:id', async (req, res) => {
|
router.patch('/:id', async (req, res) => {
|
||||||
const userId = req.headers['x-user-id'];
|
const userId = getUserId(req);
|
||||||
const { name, topic, tone, language, botToken, tgChannelId, isActive, postSchedule } = req.body;
|
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||||
const { rows } = await query(
|
try {
|
||||||
`UPDATE channels SET
|
const channel = await channelsSvc.updateChannel(req.params.id, userId, req.body);
|
||||||
name=COALESCE($1,name), topic=COALESCE($2,topic), tone=COALESCE($3,tone),
|
res.json(channel);
|
||||||
language=COALESCE($4,language), bot_token=COALESCE($5,bot_token),
|
} catch (err) {
|
||||||
tg_channel_id=COALESCE($6,tg_channel_id), is_active=COALESCE($7,is_active),
|
res.status(500).json({ error: err.message });
|
||||||
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]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/channels/:id
|
// DELETE /api/channels/:id
|
||||||
router.delete('/:id', async (req, res) => {
|
router.delete('/:id', async (req, res) => {
|
||||||
const userId = req.headers['x-user-id'];
|
const userId = getUserId(req);
|
||||||
await query(`DELETE FROM channels WHERE id=$1 AND user_id=$2`, [req.params.id, userId]);
|
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||||
|
try {
|
||||||
|
await channelsSvc.deleteChannel(req.params.id, userId);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+12
-7
@@ -1,23 +1,28 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { query } = require('../config/db');
|
const { query } = require('../config/db');
|
||||||
|
const channelsSvc = require('../services/channels');
|
||||||
const generationQueue = require('../workers/generation');
|
const generationQueue = require('../workers/generation');
|
||||||
|
|
||||||
// POST /api/generate - create generation job
|
// POST /api/generate — создать задачу генерации
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
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 (!['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(
|
const { rows } = await query(
|
||||||
`INSERT INTO generation_jobs (type, channel_id, topic, status) VALUES ($1,$2,$3,'pending') RETURNING id`,
|
`INSERT INTO generation_jobs (user_id, channel_id, type, topic, rubric, status)
|
||||||
[type, channelId || null, topic]
|
VALUES ($1,$2,$3,$4,$5,'pending') RETURNING id`,
|
||||||
|
[userId, channelId || null, type, topic || null, rubric || null]
|
||||||
);
|
);
|
||||||
const jobId = rows[0].id;
|
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' });
|
res.json({ jobId, status: 'pending' });
|
||||||
} catch (err) {
|
} 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) => {
|
router.get('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await query(`SELECT * FROM generation_jobs WHERE id=$1`, [req.params.id]);
|
const { rows } = await query(`SELECT * FROM generation_jobs WHERE id=$1`, [req.params.id]);
|
||||||
|
|||||||
+113
-56
@@ -1,45 +1,47 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
const pb = require('./promptBuilder');
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: config.ai.baseUrl,
|
baseURL: config.ai.baseUrl,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
timeout: 120000,
|
timeout: 180000,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Low-level chat completion call (OpenAI-compatible gateway)
|
* Низкоуровневый вызов chat completion.
|
||||||
* @param {string} model
|
* @returns {object} { text, usage }
|
||||||
* @param {string} systemPrompt
|
|
||||||
* @param {string} userPrompt
|
|
||||||
* @param {object} options - { maxTokens }
|
|
||||||
*/
|
*/
|
||||||
const chat = async (model, systemPrompt, userPrompt, options = {}) => {
|
async function chat(model, systemPrompt, userPrompt, options = {}) {
|
||||||
const { maxTokens = 2000 } = options;
|
const { maxTokens = 2000, temperature } = options;
|
||||||
|
|
||||||
const res = await client.post('/chat/completions', {
|
const body = {
|
||||||
model,
|
model,
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: userPrompt },
|
{ role: 'user', content: userPrompt },
|
||||||
],
|
],
|
||||||
}, {
|
};
|
||||||
|
if (temperature !== undefined) body.temperature = temperature;
|
||||||
|
|
||||||
|
const res = await client.post('/chat/completions', body, {
|
||||||
headers: { Authorization: `Bearer ${config.ai.apiKey}` },
|
headers: { Authorization: `Bearer ${config.ai.apiKey}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = res.data.choices?.[0]?.message?.content;
|
const text = res.data.choices?.[0]?.message?.content;
|
||||||
if (!text) throw new Error('Empty response from AI gateway');
|
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 { size = '1024x1024' } = options;
|
||||||
const res = await client.post('/images/generations', {
|
const res = await client.post('/images/generations', {
|
||||||
model: config.ai.models.image,
|
model: config.ai.models.image,
|
||||||
@@ -50,52 +52,107 @@ const image = async (prompt, options = {}) => {
|
|||||||
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
|
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
|
||||||
});
|
});
|
||||||
return res.data.data?.[0]?.url || res.data.data?.[0]?.b64_json;
|
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 = '' }) => {
|
async function generatePost(channel, opts = {}) {
|
||||||
const system = `Ты — профессиональный автор Telegram-каналов. Пишешь посты на ${language === 'ru' ? 'русском' : 'английском'} языке.
|
const { topic, rubric = '', useCritique = true, returnPrompt = false } = opts;
|
||||||
Тон: ${tone}.
|
if (!topic) throw new Error('topic is required');
|
||||||
${channelContext ? `Контекст канала: ${channelContext}` : ''}
|
|
||||||
Правила:
|
|
||||||
- Пост 150-400 слов
|
|
||||||
- Используй эмодзи уместно
|
|
||||||
- Не используй markdown заголовки (##, **), только текст и эмодзи
|
|
||||||
- В конце добавь 2-4 релевантных хэштега`;
|
|
||||||
|
|
||||||
const user = `Напиши пост на тему: "${topic}"`;
|
const systemPrompt = pb.buildPostSystemPrompt(channel, rubric);
|
||||||
return chat(config.ai.models.post, system, user, { maxTokens: 1000 });
|
const userPrompt = `Напиши пост на тему: "${topic}"`;
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
// Шаг 1: первичная генерация (с повышенной температурой для разнообразия)
|
||||||
* Generate a blog article
|
const first = await chat(
|
||||||
*/
|
config.ai.models.post,
|
||||||
const generateArticle = async ({ topic, language = 'ru', keywords = [] }) => {
|
systemPrompt,
|
||||||
const system = `Ты — эксперт по искусственному интеллекту, пишешь SEO-оптимизированные статьи на ${language === 'ru' ? 'русском' : 'английском'} языке.
|
userPrompt,
|
||||||
Правила:
|
{ maxTokens: 1200, temperature: 0.9 }
|
||||||
- Статья 800-1500 слов
|
);
|
||||||
- Структура: заголовок H1, введение, 3-5 разделов H2, заключение
|
|
||||||
- Используй ключевые слова органично
|
|
||||||
- Простой и понятный язык`;
|
|
||||||
|
|
||||||
const user = `Напиши статью на тему: "${topic}"${keywords.length ? `\nКлючевые слова: ${keywords.join(', ')}` : ''}`;
|
let finalText = first.text;
|
||||||
return chat(config.ai.models.article, system, user, { maxTokens: 3000 });
|
let totalUsage = { ...first.usage };
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
// Шаг 2: self-critique (опционально, дороже но качественнее)
|
||||||
* Generate topic ideas for a channel
|
if (useCritique) {
|
||||||
*/
|
|
||||||
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 {
|
try {
|
||||||
return JSON.parse(raw.replace(/```json|```/g, '').trim());
|
const critiquePrompt = pb.buildCritiquePrompt(first.text, channel);
|
||||||
} catch {
|
const critiqued = await chat(
|
||||||
return raw.split('\n').map(s => s.replace(/^[-*\d.\s]+/, '').trim()).filter(Boolean).slice(0, count);
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
+31
-12
@@ -1,12 +1,13 @@
|
|||||||
const Queue = require('bull');
|
const Queue = require('bull');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const ai = require('../services/ai');
|
const ai = require('../services/ai');
|
||||||
|
const channelsSvc = require('../services/channels');
|
||||||
const { query } = require('../config/db');
|
const { query } = require('../config/db');
|
||||||
|
|
||||||
const generationQueue = new Queue('generation', {
|
const generationQueue = new Queue('generation', {
|
||||||
redis: config.redis,
|
redis: config.redis,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 2,
|
||||||
backoff: { type: 'exponential', delay: 5000 },
|
backoff: { type: 'exponential', delay: 5000 },
|
||||||
removeOnComplete: 100,
|
removeOnComplete: 100,
|
||||||
removeOnFail: 200,
|
removeOnFail: 200,
|
||||||
@@ -14,42 +15,60 @@ const generationQueue = new Queue('generation', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
generationQueue.process(async (job) => {
|
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]);
|
await query(`UPDATE generation_jobs SET status='processing', updated_at=NOW() WHERE id=$1`, [jobId]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result;
|
let resultText;
|
||||||
|
let usage = {};
|
||||||
|
|
||||||
|
// загружаем канал если есть channelId
|
||||||
|
const channel = channelId ? await channelsSvc.getFullChannel(channelId) : null;
|
||||||
|
|
||||||
if (type === 'post') {
|
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') {
|
} 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') {
|
} else if (type === 'topics') {
|
||||||
const topics = await ai.generateTopics({ channelContext: topic, language });
|
if (!channel) throw new Error('Channel required for topic generation');
|
||||||
result = JSON.stringify(topics);
|
const r = await ai.generateTopics(channel, 10);
|
||||||
|
resultText = JSON.stringify(r.topics);
|
||||||
|
usage = r.usage;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown job type: ${type}`);
|
throw new Error(`Unknown job type: ${type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE generation_jobs SET status='done', result=$1, updated_at=NOW() WHERE id=$2`,
|
`UPDATE generation_jobs
|
||||||
[result, jobId]
|
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`);
|
console.log(`[Worker] Job ${jobId} (${type}) done. Tokens: ${usage.prompt_tokens}/${usage.completion_tokens}`);
|
||||||
return { jobId, result };
|
return { jobId, ok: true };
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await query(
|
await query(
|
||||||
`UPDATE generation_jobs SET status='failed', error=$1, updated_at=NOW() WHERE id=$2`,
|
`UPDATE generation_jobs SET status='failed', error=$1, updated_at=NOW() WHERE id=$2`,
|
||||||
[err.message, jobId]
|
[err.message, jobId]
|
||||||
);
|
);
|
||||||
|
console.error(`[Worker] Job ${jobId} failed:`, err.message);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
generationQueue.on('failed', (job, 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');
|
console.log('[Worker] Generation queue started');
|
||||||
|
|||||||
Reference in New Issue
Block a user