From 01df5d403558a6ccb6ebec8f89628e86164ea66c Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Tue, 30 Jun 2026 01:45:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(covers):=20=D0=BA=D0=BE=D0=BD=D1=86=D0=B5?= =?UTF-8?q?=D0=BF=D1=82=20=D0=BE=D0=B1=D0=BB=D0=BE=D0=B6=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D0=BC=D1=8B=D1=81=D0=BB=D1=83=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=8C=D0=B8=20(LLM)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо общей метафоры по ключевому слову — haiku придумывает конкретную визуальную сцену под конкретную статью (generateVisualConcept). Это убирает 'обложки не по теме'. При ошибке LLM — откат на getVisualMetaphor. --- src/services/covers.js | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/services/covers.js b/src/services/covers.js index 850c1ff..aa39b7f 100644 --- a/src/services/covers.js +++ b/src/services/covers.js @@ -5,6 +5,7 @@ const config = require('../config'); const { query } = require('../config/db'); const localGen = require('./localCoverGenerator'); const aiUsage = require('./aiUsage'); +const ai = require('./ai'); const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; @@ -122,12 +123,31 @@ function pickStyleIndex(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. * Рубрика полностью задаёт визуальный язык — ограничения внутри неё. */ -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 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' }, ]; 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. @@ -557,7 +577,14 @@ async function generateCover({ articleId, title, tags = [], channelId = null }) 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 usedPath = 'routerai';