Files
zeropost-engine/src/services/articleAutoSeries.js
T
Ник (Claude) 449d1fa728 AI config migration to app_settings + ai_usage logging
* 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.
2026-06-08 20:21:04 +03:00

158 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Автоматическое добавление статей в серии.
//
// Логика:
// 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 };