449d1fa728
* config/index.js: добавлен reloadAi() — асинхронно подтягивает значения из app_settings (категория ai_providers), мутирует config.ai in-place. Сервисы продолжают использовать config.ai.* синхронно. 3-уровневый fallback: app_settings.value → process.env (новое имя) → process.env (старое имя) → дефолт. * index.js: добавлен await config.reloadAi() в startup после migrate(). Добавлен middleware AsyncLocalStorage для проброса service/userId в AI-сервисы. Сервис определяется по URL-префиксу (zeropost-blog vs zeropost-tool). Подмонтирован роут /api/usage. * routes/settings.js: PUT и invalidate вызывают config.reloadAi() после изменения настройки категории ai_providers — горячая перезагрузка без рестарта PM2. * routes/usage.js (новый): GET /api/usage/summary?range&group_by — сводка расходов с разбивкой по сервису/провайдеру/модели. GET /api/usage/recent — последние вызовы. * lib/aiContext.js (новый): обёртка над AsyncLocalStorage для service/userId. * services/aiUsage.js (новый): log() с расчётом cost_rub по справочнику цен Anthropic/OpenAI (USD/1M токенов или USD/картинку) × markup × usd_rub. Никогда не бросает наружу — ошибки логирования не валят генерацию. * services/ai.js: chat() и image() обёрнуты в try/catch с aiUsage.log(). Все вышестоящие функции (generatePost, transformPost, generateTopics, generateArticle) идут через chat() — покрытие 100%. * services/covers.js: 3 call sites хукнуты (generateCoverViaResponses, generateCoverViaImagesEndpoint, generateCoverViaImageGenerations). Заодно поправлен process.env.AI_MODEL_IMAGE_VIA_RESPONSES → config.ai.imageModelViaResponses. Удалён мёртвый код после throw. * services/postImages.js: 1 call site (/responses + image_generation tool). Тот же fix env-ref. * services/articleAutoSeries.js: 1 call site (/messages, Anthropic-формат). usage.input_tokens / output_tokens корректно парсится. Verify: * Все 11 строк app_settings ai_providers заполнены значениями из .env. * Горячая перезагрузка: PUT /api/settings/admin/AI_TEXT_MODEL_POST -> runtime обновился без рестарта PM2. * Live test: ai.chat() через aiprimetech, 118+49 токенов, 0.0414 ₽ — запись в ai_usage с правильным service/provider/model. * GET /api/usage/summary возвращает корректные totals + breakdown.
158 lines
6.6 KiB
JavaScript
158 lines
6.6 KiB
JavaScript
// Автоматическое добавление статей в серии.
|
||
//
|
||
// Логика:
|
||
// 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 };
|