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:
Alexey Pavlov
2026-05-30 22:01:38 +03:00
parent 36c02a9a0a
commit 5599de59ce
7 changed files with 733 additions and 151 deletions
+114 -57
View File
@@ -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,
};
+190
View File
@@ -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,
};
+221
View File
@@ -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,
};