Commit Graph

107 Commits

Author SHA1 Message Date
Aleksei Pavlov 174c3a17c1 feat(autogen): next_topic в статусе — следующая тема для каждой категории 2026-06-21 21:38:12 +03:00
Aleksei Pavlov 1ced06fa2d fix(autogen): pg_advisory_lock + catch-up + double-check after lock
Race condition (дубли статей) устранён окончательно:

1. pg_advisory_lock по ключу категории в начале runAutogenForCategory —
   если два процесса запускаются одновременно, второй ждёт первого.
   pg_advisory_unlock в finally — освобождается всегда, даже при ошибке.

2. После получения lock — повторная проверка 'уже генерировали сегодня'
   (SELECT articles WHERE category AND created_at >= CURRENT_DATE).
   Если да — skip без генерации. Это защита от случая когда первый процесс
   завершился пока второй ждал lock.

3. draftAutoApprove catch-up при старте: если engine стартовал после 07:00
   и есть непрогнанные вчерашние черновики — одобряет их сразу.
   Раньше deploy в 07:27 приводил к тому что черновики зависали навсегда.
2026-06-21 21:30:06 +03:00
Aleksei Pavlov 630287f02f feat(autogen/queue): queue = черновики сегодня (планируется завтра) 2026-06-21 21:17:09 +03:00
Aleksei Pavlov f40bb27953 fix(autogen/status): показываем topic_count_free/total из blog_topics
getAutogenStatus теперь считает:
  - topic_count_free: свободных тем для генерации
  - topic_count: всего тем в банке
  - drafts_today: черновиков созданных за 24ч
  - cat_icon, cat_color: из таблицы categories

Убрана ссылка на несуществующую content_queue (queue_count).
2026-06-21 21:08:29 +03:00
Aleksei Pavlov 48e0bae495 fix(autogen/topics): endpoint читает из blog_topics БД вместо хардкода TOPIC_BANK
GET /api/autogen/topics теперь возвращает темы из blog_topics (свободные,
sorted by priority DESC), backward-compat: возвращает строки как раньше.
Fallback на TOPIC_BANK если для категории нет тем в БД.
2026-06-21 21:00:08 +03:00
Aleksei Pavlov 45c3f2b562 fix(blog-topics): ai.chat returns object not string — extract .text before .replace
После обновления ai.js модуль возвращает { text, usage } вместо строки.
result.replace() падало с 'not a function'. Теперь берём .text если объект.
2026-06-21 20:57:28 +03:00
Aleksei Pavlov c920d3bcd5 fix(autogen): атомарный захват темы — исключает дубли статей
Проблема: одна и та же тема из blog_topics использовалась несколько раз
(см. 7 дублей в БД). Причина: getNextTopic читал is_used=false, возвращал
тему, но помечал её использованной только ПОСЛЕ INSERT статьи (через 30-60 сек
пока AI генерирует текст). За это время повторный запуск видел ту же is_used=false
и выбирал её снова.

Фикс: атомарная операция UPDATE...RETURNING с FOR UPDATE SKIP LOCKED —
тема помечается is_used=true В МОМЕНТ ВЫБОРА, до вызова AI.
Параллельные или повторные запуски видят is_used=true и пропускают тему.

Дополнительно: fallback TOPIC_BANK теперь перемешивает пул случайно
(вместо Math.random() на весь массив) — более равномерное распределение.
2026-06-21 16:47:49 +03:00
Aleksei Pavlov 214bf307c7 fix(draftAutoApprove): строго вчерашние черновики, LIMIT 4, не больше слотов
Проблема: брали черновики за 36ч → при нескольких деплоях за день или при
накоплении старых черновиков draftAutoApprove раскладывал их все по слотам
подряд → очередь на несколько дней вместо 1.

Исправления:
  - WHERE created_at AT TIME ZONE 'Europe/Moscow' вчерашний день (00:00-23:59)
  - LIMIT 4 — не больше количества слотов в день
  - раскладываем только min(drafts, freeSlots) черновиков, лишние остаются draft
  - если черновиков > слотов — пишем в лог предупреждение
2026-06-21 16:42:37 +03:00
Aleksei Pavlov 799816f66a fix(drafts): авто-одобрение только вчерашних черновиков → слоты сегодняшнего дня
Было: runDraftAutoApprove брал ВСЕ черновики подряд и вызывал nextDripSlot()
для каждого — слоты набивались на несколько дней вперёд.

Теперь:
- берём только черновики созданные за последние 36 часов (вчерашние)
- слоты — только сегодняшний день (getTodaySlots из SITE_PUBLISH_SLOTS)
- занятые слоты пропускаем, не затираем уже запланированное
- если слотов меньше черновиков — ставим оставшихся в последний слот

Механика как у заметок Зеро:
  17:00 → генерация 4 черновиков (лежат в /admin/drafts, можно редактировать)
  07:00 след.дня → авто-одобрение, слоты сегодняшнего дня (08:11/12:11/16:11/20:11)
  Ручное одобрение → nextDripSlot (ближайший свободный)
2026-06-21 16:36:47 +03:00
Aleksei Pavlov 749d717a94 feat(publish): синхрон сайт/ТГ + защита от залпа + заметки Зеро по механике черновиков
ЭТАП 4 — расписание публикаций (раз и навсегда):

1. Синхронизация сайт ↔ Telegram:
   - articleAutoPublish.pickScheduleTime теперь ставит TG-пост на
     articles.published_at (тот же момент, когда статья появляется на сайте).
     Слоты канала publish_slots больше НЕ выбирают время независимо.
   - Единый источник времени — published_at (drip-слот сайта).
   - Слоты сайта и ТГ синхронизированы в БД: 08:11/12:11/16:11/20:11.

2. Защита от залпа (scheduledPostsRunner):
   - посты просроченные >3ч → status='skipped' (не спамим канал задним числом).
   - публикация по ОДНОМУ за тик (LIMIT 1), не пачкой.

3. Заметки Зеро (zeroNotes) — механика как у черновиков статей:
   - nextPublishSlot всегда = ЗАВТРА publishHour (сегодня сгенерили → завтра
     публикуем). Час настраивается ZERO_NOTES_PUBLISH_HOUR (13:00).
   - autoApproveOldDrafts (09:00 МСК): если не подтверждён к утру дня
     публикации — авто-одобряется и выходит в свой слот.
   - publishReady limit:1 — строго одна заметка за тик.

Настройки в app_settings: SITE_PUBLISH_SLOTS, ZERO_SITE_URL_BASE,
ZERO_NOTES_APPROVE_HOUR=9, GENERATE_HOUR=13, PUBLISH_HOUR=13.
2026-06-20 11:07:10 +03:00
Aleksei Pavlov a09ee4a5fb refactor(tg): единый модуль tgSend для всех публикаций в Telegram
Проблема: scheduledPostsRunner (статьи) и zeroNotesRunner (заметки Зеро)
держали каждый свою копию sendPhoto/sendMessage. Логика разъезжалась —
у статей фото работало через multipart, у заметок ломалось (URL-режим →
'wrong type of web page content'). Чинили в одном месте — в другом
оставалось сломано.

Решение: src/services/tgSend.js — единый источник правды:
  - resolveLocalPhoto: /uploads/* → локальный файл (multipart), внешний → URL
  - tgSend({botToken, chatId, text, photoUrl, replyMarkup, parseMode}):
      фото+caption<=1024 → sendPhoto (multipart для локальных, URL для внешних)
      иначе → sendMessage (текст<=4096)
  - extractTgError: единый разбор ошибок Telegram

zeroNotesRunner и scheduledPostsRunner.publishToTelegram теперь оба зовут
tgSend. Кнопка-на-сайт у статей и reply_markup у заметок сохранены.
parseMode: статьи — Markdown, заметки Зеро — без разметки (текст от первого
лица с произвольными символами не должен падать на parse entities).

VK/MAX публикация не затронута (там свой resolveLocalPhoto/FormData).
2026-06-20 10:43:50 +03:00
Aleksei Pavlov 325ebe7759 fix(zero): back to multipart photo upload (engine now has uploads volume)
The URL-mode I added earlier (Telegram fetches photo from our public URL)
hit 'wrong type of the web page content' from Telegram servers — likely
because traefik / Cloudflare serves something non-image to bot User-Agent,
or the worker proxy mangles things on the way back. Either way it stopped
working.

Today we added the persistent /var/www/zeropost-uploads volume to the engine
container, so the file is now directly accessible inside engine. Switching
back to multipart upload via fs.createReadStream — same pattern as
scheduledPostsRunner uses for article covers (and which works fine).

ZERO_PUBLIC_BASE_URL setting is no longer needed but harmless if left.
2026-06-20 10:26:13 +03:00
Aleksei Pavlov bdff84e579 feat(articles): drip scheduling — distribute published_at across day slots
Зачем: автоген генерит несколько статей подряд (4 категории за 15 минут).
Раньше у всех published_at=NOW(), и на сайте они появлялись скопом, выглядя
как спам. Теперь распределяем по слотам сайта (по умолчанию 09/13/17/21 МСК).

Архитектура:
  src/services/dripScheduler.js — nextDripSlot() читает app_settings.SITE_PUBLISH_SLOTS
    (CSV 'HH:MM,HH:MM,...'), default '09:00,13:00,17:00,21:00'. Перебирает дни
    вперёд (14 дней горизонт), для каждого слота проверяет ±60 мин окно —
    если уже есть опубликованная статья, считает занятым. Первый свободный
    слот возвращается. Slots в MSK, конвертируются в UTC для сравнения с NOW().

При approve draft → published:
  routes/drafts.js — PATCH /:id/approve по умолчанию ставит slot, ?immediate=true
    публикует немедленно. GET /next-slot возвращает ближайший свободный для
    UI-предпросмотра.
  draftAutoApprove.js — авто-approve в 07:00 МСК тоже использует slot.

Публичные SQL дополнены 'AND published_at <= NOW()' чтобы будущие статьи
не светились на сайте раньше времени:
  - services/articles.js: 7 мест (list/get/count/hero/grid/popular/recent)
  - routes/categories.js: GET /:slug/articles
  - routes/scheduledPosts.js: TG-publish source query

Опционально: SITE_PUBLISH_SLOTS можно редактировать в /admin/settings.
2026-06-19 13:01:36 +03:00
Aleksei Pavlov b02bdba4e6 fix(zero): URL-mode photo sending + static AVAILABLE_POSES (no fs check)
Problem: engine container has no mount to /var/www/zeropost-uploads/, so
fs.existsSync() always returned false. This made image_url=null for every
Zero draft, and the TG runner fell back to sendMessage (text-only, no avatar).

Fix:
  - zeroCharacter.js: drop fs.existsSync entirely; AVAILABLE_POSES is now a
    static Set synced with what's actually on uploads-server (23 poses
    including avatar/eureka/lock/gears/etc. — earlier I incorrectly thought
    there were only 10). FALLBACK_POSE = 'coffee' (more on-brand than avatar).
  - zeroNotes.js: image_url unconditionally '/uploads/zero-{pose}.webp';
    pickPose now also looks at the generated content (not only theme).
  - zeroNotesRunner.js: sendPhoto by URL (Telegram fetches the image itself).
    No more form-data/createReadStream — full URL composed from
    ZERO_PUBLIC_BASE_URL setting (default https://zeropost.ru).
  - zeroAdmin.js: ZERO_PUBLIC_BASE_URL added to CONFIG_KEYS so it's editable
    via /admin/zero settings panel.

Existing draft #1 backfilled via SQL UPDATE (pose=eureka → image_url set).
2026-06-19 12:17:53 +03:00
Aleksei Pavlov 7b115deaa1 feat(blog-topics): PATCH endpoint + soften requireAdmin + dynamic category name
- requireAdmin softened: doesn't require x-user-id when users.is_admin column
  is absent (same pattern as /api/admin/zero). Auth still enforced via the
  global x-internal-secret middleware.
- /api/admin/blog-topics/:id now supports PATCH (topic/tags/priority/is_used).
- /api/admin/blog-topics/generate reads category name from categories table
  instead of using a hardcoded 4-entry map, so AI-generation works for any
  user-created category.
2026-06-19 12:05:04 +03:00
Aleksei Pavlov 59e604a67b feat(categories): CRUD endpoints + is_active for archiving
Endpoints under /api/admin/categories:
  GET    — list all with metrics (article_count, topic_count, autogen status)
  POST   — create + auto-create autogen_settings row (defaults: enabled, 1/day, 12:00 MSK)
  PATCH  — update name/desc/icon/color/sort_order/is_active (slug is immutable FK)
  DELETE — soft (is_active=false) by default; ?force=true tries hard delete
           but refuses if any articles/topics still reference the slug

Migration: ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true
Public GET /api/categories now filters WHERE COALESCE(is_active,true)=true.
2026-06-19 11:55:04 +03:00
Aleksei Pavlov 2f7af84ddc fix(zero): add missing 'settings' require in scheduler 2026-06-19 11:19:24 +03:00
Aleksei Pavlov 4ffadc6baa feat(zero): /config endpoints + dynamic scheduler hours
- GET/PATCH /api/admin/zero/config — read/write all Zero settings via app_settings
  - scheduler reads GENERATE_HOUR / APPROVE_HOUR dynamically (no restart needed)
  - generateDraft uses PUBLISH_HOUR for scheduled_at (was hardcoded 13)
  - requireAdmin softened — works both with and without users.is_admin column
    (prod has no is_admin; auth is provided by x-internal-secret + web cookie)
2026-06-19 11:16:58 +03:00
Aleksei Pavlov 29788a8f9d feat(zero): Zero notes — AI persona for @zeropostru daily posts
Adds character-driven AI-generated notes pipeline parallel to articles:
  - migration: zero_notes table (channel-scoped, status flow draft→approved→published)
  - services/zeroPrompt.js — 12 theme buckets, few-shots, anti-repeat by bucket,
    sha256 theme_hash for dedup
  - services/zeroNotes.js — generateDraft/approve/skip/edit/autoApproveOldDrafts
  - services/zeroNotesRunner.js — TG publication with multipart pose images,
    FOR UPDATE SKIP LOCKED claim, retry up to 3 attempts
  - workers/zeroNotesScheduler.js — 13:00 MSK generate, 07:00 MSK auto-approve,
    publish runner every 60s
  - routes/zero.js (public, no secret) — character bio + published notes for site
  - routes/zeroAdmin.js — full CRUD + manual generate button + regenerate

Settings (app_settings):
  ZERO_NOTES_CHANNEL_IDS  — csv int, channels to post for (required to enable)
  ZERO_NOTES_MODEL        — defaults to AI_MODEL_POST
  ZERO_SITE_URL_BASE      — optional, adds 'Open on site' inline button
2026-06-19 10:52:31 +03:00
Nik (Claude) 5b5f703078 fix: avoid repeating last 3 rubrics in cover selection (no more similar covers) 2026-06-19 01:09:52 +03:00
Nik (Claude) eca072a172 fix: start autogen scheduler in index.js (was missing, drafts never generated) 2026-06-19 00:55:48 +03:00
Nik (Claude) 707047a7af fix: scheduleForArticle picks unique slots, no collisions 2026-06-18 12:25:09 +03:00
Nik (Claude) 08a2628824 feat: add Dockerfile for Coolify deployment (volumes support) 2026-06-18 12:12:32 +03:00
Nik (Claude) efe85632f5 fix: use .png instead of .webp for Telegram photo URL 2026-06-17 09:27:25 +03:00
Nik (Claude) c147c9e839 fix: start scheduledPostsRunner in index.js 2026-06-17 09:06:28 +03:00
Nik (Claude) dccb662298 fix: move approve-all before /:id routes to prevent NaN routing conflict 2026-06-16 23:11:14 +03:00
Nik (Claude) f9d1deae58 fix: initialize usedPath in covers.js to prevent ReferenceError 2026-06-16 22:58:28 +03:00
Nik (Claude) 0a842476d7 fix: reduce article maxTokens 4000→3000 to avoid 400 on aiprimetech 2026-06-16 22:08:44 +03:00
Nik (Claude) e5965e2804 feat: POST /api/articles/:id/regenerate-cover for any status 2026-06-16 22:02:34 +03:00
Nik (Claude) d9cbbc5fbf fix: button always shows DEFAULT_BUTTON_TEXT when field is null/empty 2026-06-16 21:14:57 +03:00
Nik (Claude) 5852b9f439 feat: draft review flow — autogen→draft, auto-approve 07:00 MSK, /api/drafts routes 2026-06-16 09:17:10 +03:00
Nik (Claude) cd471d67a9 add start script for Coolify nixpacks 2026-06-15 22:44:05 +03:00
Alexey Pavlov bede92a520 feat: post image diversity — style rotation + random scene/concept + expanded AI concepts bank 2026-06-15 10:20:22 +03:00
Nik (Claude) 525870c709 chore: add full schema dump as source of truth (32 tables) 2026-06-15 09:28:13 +03:00
Ник (Claude) 31b31b75b8 fix: post images diversity — SUBJECT+SETTING+LIGHTING prompt
Та же проблема что и у обложек статей:
  старый промт 'Topic essence: первые 250 символов' был слишком абстрактным
  → модель рисовала свой дефолт (тёмный фон + геометрия)

Новый промт (3 некофликтующих параметра):
  VISUAL CONCEPT: конкретный предмет из getPostVisualConcept()
  SETTING: физический антураж (8 вариантов по seed из текста поста)
  LIGHTING + COLOR TEMPERATURE: конкретный свет

getPostVisualConcept(): 8 тематических категорий × 4-6 концептов
  AI, automation, cybersec, code, marketing, money, education, health
  + 12 универсальных концептов
Seed = hash первых 80 символов поста → детерминировано но уникально
2026-06-14 15:13:20 +03:00
Ник (Claude) c40ef90ad1 feat: SMTP, maintenance mode, blog topic bank UI
8. SMTP: emailService.js (nodemailer), templates (welcome/payment/low_credits)
   /api/admin/email/test — тест отправки
   app_settings category=smtp (HOST/PORT/USER/PASS/FROM/ENABLED)
9. Maintenance mode: middleware в index.js, MAINTENANCE_MODE в engine settings
   При true → 503 для всех запросов кроме /uploads и /api/settings
10. Blog topic bank:
   DB: blog_topics(category,topic,is_used,source,priority)
   40 тем мигрированы из хардкода (source=hardcoded)
   autogen.js: getNextTopic берёт из DB, fallback на TOPIC_BANK
   admin API: GET/POST /blog-topics, DELETE /:id, POST /generate (AI +10)
2026-06-13 11:45:23 +03:00
Ник (Claude) 9b40f2cd7a feat: content defaults — applied on channel creation
DB: app_settings category=content (10 keys)
channels.js: createChannel reads DEFAULT_* from settings and applies to new channel
  DEFAULT_POST_LANGUAGE/LENGTH/STYLE/GOAL → channel.goal, language
  DEFAULT_IMAGE_ENABLED → channel.image_enabled
  DEFAULT_AI_STYLE_PROMPT → channel.ai_style_prompt
  DEFAULT_AUTO_DRAFT_COUNT/TIME → channel.auto_draft_count/time
channels: image_enabled BOOLEAN DEFAULT true
2026-06-13 11:22:08 +03:00
Ник (Claude) b5fa77ea01 feat: autogen blog admin API
routes/admin.js:
  GET  /autogen — настройки+статистика+очередь+размеры банков тем
  PATCH /autogen/:category — enabled/per_day/run_hour/run_minute
  POST  /autogen/:category/run — ручной запуск генерации
  POST  /autogen/queue — добавить тему с приоритетом
  DELETE /autogen/queue/:id — удалить тему
2026-06-13 10:35:50 +03:00
Ник (Claude) 6e1cd24b4e feat: error logs API
routes/admin.js: GET /logs — объединённые ошибки из 3 источников:
  generation_jobs (status=failed), ai_usage (!succeeded), scheduled_posts (status=failed)
  Сортировка по времени, топ-5 частых ошибок, группировка по типу
2026-06-13 10:23:24 +03:00
Ник (Claude) 7994b0e73c feat: generation queue admin
routes/admin.js: GET /queue (stats+recent30+stuck), POST /queue/:id/retry, DELETE /queue/stuck
  stuck = processing > 5 min → сбрасываем в failed
  retry = pending + requeue через Bull
2026-06-13 10:13:21 +03:00
Ник (Claude) ce74ac9909 feat: promo codes system
DB: promo_codes, promo_usages tables
routes/admin.js: CRUD /api/admin/promos (GET/POST/PATCH/DELETE)
routes/billing.js: POST /api/billing/apply-promo
  Валидация: exists, active, not expired, not exhausted, not used by this user
  type=credits → начисляет через billing.credit()
2026-06-13 09:36:32 +03:00
Ник (Claude) 2360e1f7ae fix: cover images — simplified coherent prompts, no style conflicts
Проблема: VISUAL CONCEPT и STYLE из COVER_STYLES противоречили друг другу
  (sculptor on marble + frosted glass style = модель рисовала свой дефолт)

Решение: 3 простых параметра на статью без конфликтов:
  SUBJECT: что изображено (из getVisualMetaphor)
  SETTING: антураж (12 вариантов по articleId % 12):
    oak desk | marble | slate | workbench | velvet | dawn mist |
    terracotta | city night | concrete | library | frost | brick
  LIGHTING: конкретный свет (golden hour, studio, rim light, etc.)
  COLOR TEMPERATURE: warm amber / cool whites / etc.

Убраны STYLE/PALETTE/MOOD/COMPOSITION блоки которые путали модель.
Теперь каждая статья = уникальная физическая сцена с конкретным светом.
2026-06-13 09:29:40 +03:00
Ник (Claude) 170c7b7b16 fix: cover image variety — concrete metaphors + articleId cycling
getVisualMetaphor():
- articleId % array.length как чистый индекс цикличности
- Метафоры конкретные/материальные (ключи, телескопы, часы, книги)
  вместо абстрактных neural nodes которые все выглядят одинаково
- AI категория: 14 метафор (повтор через 14 статей = ~5 дней)
- 11 тематических категорий + 15 универсальных
2026-06-13 09:26:36 +03:00
Ник (Claude) 05fa7644cc feat: user management — detail view, block/unblock, plan change
routes/admin.js: GET /users/:id (profile+channels+balance+transactions)
  PATCH /users/:id (is_blocked, plan_code, name)
  plan change: cancels active sub → creates new → credits reset
generate.js: check is_blocked before generation → 403 ACCOUNT_BLOCKED
DB: users.is_blocked BOOLEAN DEFAULT false
2026-06-13 00:14:11 +03:00
Ник (Claude) f18b83c59b feat: admin dashboard API + separate admin routes file
routes/admin.js: GET /dashboard, /users, POST /credit, PATCH /plans/:id, /credit-costs/:op
index.js: app.use('/api/admin', adminRoutes) — чистый монтаж без хаков
dashboard: users (total/7d/30d), channels by platform, posts (total/today/week),
  revenue (YuKassa), AI costs (30d), registrations chart (14d), pending drafts alert
2026-06-13 00:09:53 +03:00
Ник (Claude) ad9f054701 feat: admin panel — plans editor + credit costs editor
routes/billing.js: PATCH /api/admin/plans/:id, PATCH /api/admin/credit-costs/:operation
index.js: /api/admin/* → billing routes
DB: AI_IMAGE_* → category=legacy (скрыты из UI)
     engine settings: ENGINE_PUBLIC_URL, APP_PUBLIC_URL, AUTO_DRAFT_DEFAULT_*
2026-06-13 00:02:03 +03:00
Ник (Claude) 2b996820d7 fix: routerai cost_rub was 0 — o?.promptTokens → promptTokens in computeCostRub
aiUsage.js: computeCostRub получает распакованные параметры, не объект o
Backfill: UPDATE 8 записей с cost_rub=0 → ₽3.12
2026-06-12 23:56:45 +03:00
Ник (Claude) a8ff295faa feat: post drafts system — batch generation + daily auto-drafts
DB: post_drafts(channel_id, topic, text, image_url, status), channels.auto_draft_*
Engine:
  services/draftService.js: generateOneDraft, generateBatch, generateDailyDrafts,
    approveDraft(→scheduled_post), rejectDraft, updateDraft, listDrafts
  routes/drafts.js: GET/PATCH/DELETE /api/drafts/:id, /approve, /reject
    POST /api/channels/:channelId/drafts/generate?count=N (async, returns immediately)
  index.js: cron каждые 30 мин → generateDailyDrafts() для каналов с auto_draft_enabled
  channels.js: updateChannel сохраняет auto_draft_enabled/count/time
2026-06-12 23:47:27 +03:00
Ник (Claude) 5a765d27e1 fix: cover image diversity — 12 styles + topic-aware visual metaphors
COVER_STYLES: 4 → 12 стилей (amber-terrain, violet-gradient, monochrome-sharp,
  coral-horizon, neon-circuits, blueprint-tech, glass-morphism, retro-wave,
  zen-minimal, data-cosmos, editorial-ink, teal-architecture)
  Теперь стиль повторяется раз в 12 статей (было каждые 4)

buildCoverPrompt(): новая структура промта:
  - VISUAL CONCEPT: тематическая метафора из getVisualMetaphor()
  - STYLE/PALETTE/MOOD/COMPOSITION: стиль из ротации
  Промт явно ставит концепцию первой → картинка отражает тему статьи

getVisualMetaphor(): 10 тематических категорий (cybersec, AI, automation,
  data, deepfake, code, marketing, email, vector/rag, prompt engineering)
  + 10 универсальных метафор. Детерминированный выбор по хешу заголовка.
2026-06-12 22:55:25 +03:00
Ник (Claude) 7a70f79e61 fix: duplicate article prevention — source_topic deduplication
autogen.js: getNextTopic() теперь проверяет source_topic (exact match) вместо
  сравнения первых 20 символов заголовка (который AI переименовывает)
articles.js: INSERT сохраняет source_topic из topic параметра
DB: articles.source_topic TEXT, articles.topic_hash VARCHAR(64)
Пометили существующие дубли: article 61 → archived, source_topic заполнен
2026-06-12 11:49:33 +03:00