// Автоматическое добавление статей в серии. // // Логика: // 1. При публикации статьи — Claude haiku анализирует заголовок + excerpt // 2. Определяет наиболее подходящую серию (или ни одну) // 3. Добавляет article.id в series.article_ids если его там ещё нет // // Серии и их описания для Claude: // prompts — промпты, инструкции, генерация текста/изображений, работа с LLM как инструментом // mcp-agents — RAG, агенты, MCP, Telegram/API боты, интеграции ИИ с внешними системами // cases — автоматизация рабочих процессов, реальные кейсы, Make/Zapier/n8n, CRM, email const axios = require('axios'); const { query } = require('../config/db'); const config = require('../config'); const aiUsage = require('./aiUsage'); const SERIES_DESCRIPTIONS = [ { slug: 'prompts', name: 'Промпт-инжиниринг', keywords: 'промпты, инструкции для ИИ, генерация текста, генерация изображений, работа с ChatGPT/Claude как инструментом, техники промптинга, few-shot, chain-of-thought, техдокументация с ИИ', }, { slug: 'mcp-agents', name: 'MCP и агенты', keywords: 'RAG, векторные базы данных, ИИ-агенты, MCP, автономные боты, Telegram-бот с ИИ, интеграции ИИ с API, LangChain, LlamaIndex, инструменты для агентов', }, { slug: 'cases', name: 'Кейсы и автоматизации', keywords: 'автоматизация рабочих процессов, Make, Zapier, n8n, CRM, email-маркетинг, реальные кейсы применения ИИ в работе, экономия времени, пайплайны', }, { slug: 'ai-security', name: 'Безопасность в эпоху ИИ', keywords: 'кибербезопасность с ИИ, prompt injection, OSINT, социальная инженерия, атаки на LLM, безопасность продакшна, анализ малвари, защита данных, LLM уязвимости', }, ]; /** * Определить подходящую серию для статьи через Claude haiku. * Возвращает slug серии или null если статья ни к одной не подходит. */ async function detectSeries(article) { const seriesList = SERIES_DESCRIPTIONS.map(s => `- "${s.slug}" (${s.name}): ${s.keywords}` ).join('\n'); const prompt = `Ты — редактор блога ZeroPost. Определи, подходит ли эта статья к одной из серий блога. СТАТЬЯ: Заголовок: ${article.title} Описание: ${article.excerpt || ''} Категория: ${article.category || ''} СЕРИИ БЛОГА: ${seriesList} Отвечай ТОЛЬКО одним словом — slug серии (prompts / mcp-agents / cases) или "none" если статья ни к одной не подходит достаточно хорошо. Выбирай серию только если уверен на 80%+. Лучше "none" чем неточное попадание.`; const model = config.ai.models?.post || 'claude-haiku-4-5-20251001'; const started = Date.now(); try { const res = await axios.post( `${config.ai.baseUrl}/messages`, { model, max_tokens: 10, messages: [{ role: 'user', content: prompt }], }, { headers: { Authorization: `Bearer ${config.ai.apiKey}` }, timeout: 15000, } ); const usage = res.data?.usage || {}; aiUsage.log({ provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl), requestType: 'chat', model, promptTokens: usage.input_tokens, completionTokens: usage.output_tokens, requestId: res.data?.id, meta: { purpose: 'auto-series-detection' }, durationMs: Date.now() - started, succeeded: true, }).catch(() => {}); const raw = res.data?.content?.[0]?.text?.trim().toLowerCase() || 'none'; // Извлекаем только slug без лишнего текста const valid = SERIES_DESCRIPTIONS.map(s => s.slug); const found = valid.find(s => raw.includes(s)); return found || null; } catch (err) { aiUsage.log({ provider: aiUsage.providerFromBaseUrl(config.ai.baseUrl), requestType: 'chat', model, meta: { purpose: 'auto-series-detection' }, durationMs: Date.now() - started, succeeded: false, errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500), }).catch(() => {}); console.warn('[AutoSeries] Claude detection failed:', err.message.slice(0, 100)); return null; } } /** * Добавить статью в подходящую серию. * Идемпотентно — не добавляет дубли. * @returns { slug, seriesTitle } или null */ async function addToSeries(articleId) { // Загружаем статью const { rows: arts } = await query( `SELECT id, title, excerpt, category, status FROM articles WHERE id=$1`, [articleId] ); if (!arts.length || arts[0].status !== 'published') return null; const article = arts[0]; // Определяем серию const slug = await detectSeries(article); if (!slug) { console.log(`[AutoSeries] article=${articleId} → no suitable series`); return null; } // Загружаем серию const { rows: series } = await query( `SELECT id, title, article_ids FROM series WHERE slug=$1`, [slug] ); if (!series.length) return null; const s = series[0]; const currentIds = (s.article_ids || []).map(Number); if (currentIds.includes(articleId)) { console.log(`[AutoSeries] article=${articleId} already in series "${slug}"`); return { slug, seriesTitle: s.title, alreadyIn: true }; } // Добавляем в конец const newIds = [...currentIds, articleId]; await query( `UPDATE series SET article_ids=$1::jsonb, updated_at=NOW() WHERE id=$2`, [JSON.stringify(newIds), s.id] ); console.log(`[AutoSeries] article=${articleId} "${article.title.slice(0,40)}" → series "${slug}" (${newIds.length} total)`); return { slug, seriesTitle: s.title, articleCount: newIds.length }; } module.exports = { addToSeries, detectSeries, SERIES_DESCRIPTIONS };