feat(covers): концепт обложки по смыслу статьи (LLM)

Вместо общей метафоры по ключевому слову — haiku придумывает конкретную
визуальную сцену под конкретную статью (generateVisualConcept). Это убирает
'обложки не по теме'. При ошибке LLM — откат на getVisualMetaphor.
This commit is contained in:
Alexey Pavlov
2026-06-30 01:45:12 +03:00
parent b56f52a751
commit 01df5d4035
+30 -3
View File
@@ -5,6 +5,7 @@ const config = require('../config');
const { query } = require('../config/db'); const { query } = require('../config/db');
const localGen = require('./localCoverGenerator'); const localGen = require('./localCoverGenerator');
const aiUsage = require('./aiUsage'); const aiUsage = require('./aiUsage');
const ai = require('./ai');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
@@ -122,12 +123,31 @@ function pickStyleIndex(articleId) {
/** /**
* Промпт для обложки — стиль выбирается по articleId, содержание по теме статьи. * Промпт для обложки — стиль выбирается по articleId, содержание по теме статьи.
*/ */
/**
* Концепт обложки от LLM — придумывает конкретную визуальную сцену ПО СМЫСЛУ статьи.
* Дешёвый haiku-вызов. Это устраняет «обложки не по теме»: вместо общей метафоры
* по ключевому слову модель читает заголовок и предлагает сцену именно под эту статью.
* При ошибке возвращает null → buildCoverPrompt откатится на getVisualMetaphor.
*/
async function generateVisualConcept({ title, tags = [], excerpt = '' }) {
try {
const sys = 'You are an art director for a tech/AI blog. Given an article, invent ONE concrete, literal visual scene for its cover that clearly fits THIS article\'s specific topic. One clear subject or visual metaphor, photographable or illustratable, with a setting and lighting. Avoid generic "robot at a desk" clichés. STRICTLY: no text, letters, numbers, logos, UI, or real human faces. Answer in English, 1-2 sentences, only the scene description.';
const user = `Title: "${title}"\nTags: ${(tags || []).join(', ') || 'none'}${excerpt ? `\nExcerpt: ${excerpt.slice(0, 400)}` : ''}\n\nCover scene:`;
const res = await ai.chat(config.ai.models.post || 'claude-haiku-4-5-20251001', sys, user, { maxTokens: 120, temperature: 0.9 });
const text = (res?.text || '').trim().replace(/^["']|["']$/g, '');
return text.length > 10 ? text : null;
} catch (err) {
console.warn('[Cover] visual concept LLM failed, fallback to metaphor:', (err.message || '').slice(0, 80));
return null;
}
}
/** /**
* Промпт для обложки. * Промпт для обложки.
* Приоритет: rubric.prompt → channelStyle.image_style → COVER_STYLES rotation. * Приоритет: rubric.prompt → channelStyle.image_style → COVER_STYLES rotation.
* Рубрика полностью задаёт визуальный язык — ограничения внутри неё. * Рубрика полностью задаёт визуальный язык — ограничения внутри неё.
*/ */
function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null, rubric = null }) { function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null, rubric = null, concept = null }) {
const subject = title.replace(/[«»":?!.]/g, '').slice(0, 100); const subject = title.replace(/[«»":?!.]/g, '').slice(0, 100);
const tagHint = tags.slice(0, 2).join(', '); const tagHint = tags.slice(0, 2).join(', ');
@@ -196,7 +216,7 @@ function buildCoverPrompt({ title, tags = [], articleId = 0, channelStyle = null
lighting: 'soft side window light', temp: 'warm brick reds and natural wood' }, lighting: 'soft side window light', temp: 'warm brick reds and natural wood' },
]; ];
const scene = SCENES[articleId % SCENES.length]; const scene = SCENES[articleId % SCENES.length];
const visualConcept = getVisualMetaphor(title, tags, articleId); const visualConcept = concept || getVisualMetaphor(title, tags, articleId);
return `Generate a 16:9 editorial cover photograph or illustration. return `Generate a 16:9 editorial cover photograph or illustration.
@@ -557,7 +577,14 @@ async function generateCover({ articleId, title, tags = [], channelId = null })
console.log(`[Cover] article=${articleId} channel=${channelId || 'none'} style=${styleName}`); console.log(`[Cover] article=${articleId} channel=${channelId || 'none'} style=${styleName}`);
} }
const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric }); // Концепт обложки ПО СМЫСЛУ статьи (LLM). Только без жёсткой рубрики.
let concept = null;
if (!selectedRubric) {
concept = await generateVisualConcept({ title, tags });
if (concept) console.log(`[Cover] article=${articleId} concept="${concept.slice(0, 90)}"`);
}
const prompt = buildCoverPrompt({ title, tags, articleId, channelStyle, rubric: selectedRubric, concept });
let img = null; let img = null;
let usedPath = 'routerai'; let usedPath = 'routerai';