diff --git a/draftAutoApprove.js b/draftAutoApprove.js index f628c2d..57b954c 100644 --- a/draftAutoApprove.js +++ b/draftAutoApprove.js @@ -43,14 +43,16 @@ async function getTodaySlots() { async function runDraftAutoApprove() { try { - // Берём черновики созданные ВЧЕРА по МСК (между 00:00 и 23:59 вчера). - // Строго только вчерашние — чтобы при повторном деплое/рестарте не захватить - // старые черновики и не создать очередь на несколько дней. + // Берём самые СТАРЫЕ черновики, созданные НЕ сегодня (по МСК). + // Раньше окно было «строго вчера» + LIMIT 4 — поэтому всё, что не одобрили + // на следующий день (дубли, излишки), оставалось в draft навсегда и копилось. + // Теперь дренируем backlog по 4 старейших в день, при этом сегодняшнюю свежую + // партию не трогаем (она станет доступна завтра). В связке с дедупом автогена + // (макс 4/день) это даёт устойчивое состояние без накопления. const { rows: drafts } = await query( `SELECT id, title, category, created_at FROM articles WHERE status='draft' - AND created_at AT TIME ZONE 'Europe/Moscow' >= (CURRENT_DATE - INTERVAL '1 day')::date - AND created_at AT TIME ZONE 'Europe/Moscow' < CURRENT_DATE::date + AND created_at AT TIME ZONE 'Europe/Moscow' < CURRENT_DATE::date ORDER BY created_at ASC LIMIT 4` ); @@ -111,7 +113,7 @@ async function runDraftAutoApprove() { } function startDraftAutoApproveScheduler() { - console.log('[DraftApprove] scheduler started (auto-approve at 07:00 MSK, только вчерашние черновики)'); + console.log('[DraftApprove] scheduler started (auto-approve at 07:00 MSK, дренаж backlog: 4 старейших/день, не трогая сегодняшние)'); // Catch-up при старте: если сейчас уже после AUTO_APPROVE_HOUR_MSK — // проверить есть ли вчерашние черновики которые пропустили тик. @@ -123,8 +125,7 @@ function startDraftAutoApproveScheduler() { const { rows: missed } = await query(` SELECT COUNT(*) AS cnt FROM articles WHERE status='draft' - AND created_at AT TIME ZONE 'Europe/Moscow' >= (CURRENT_DATE - INTERVAL '1 day')::date - AND created_at AT TIME ZONE 'Europe/Moscow' < CURRENT_DATE::date + AND created_at AT TIME ZONE 'Europe/Moscow' < CURRENT_DATE::date `); if (parseInt(missed[0]?.cnt, 10) > 0) { console.log('[DraftApprove] catch-up при старте: найдены пропущенные вчерашние черновики (' + missed[0].cnt + ' шт)'); diff --git a/src/routes/drafts.js b/src/routes/drafts.js index 1d591c3..8e8d47d 100644 --- a/src/routes/drafts.js +++ b/src/routes/drafts.js @@ -25,6 +25,23 @@ router.get('/cover-styles', (req, res) => { res.json(COVER_STYLES.map(s => ({ id: s.name, name: s.name }))); }); +// GET /api/drafts/:id — полный черновик с content (для чтения статьи целиком) +// Определён ПОСЛЕ /cover-styles, чтобы тот не матчился как :id. +router.get('/:id', async (req, res) => { + try { + const { rows } = await query( + `SELECT id, slug, title, excerpt, content, cover_url, category, tags, + reading_time, seo_title, seo_descr, source_topic, created_at + FROM articles WHERE id=$1 AND status='draft'`, + [req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: 'Draft not found' }); + res.json(rows[0]); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // PATCH /api/drafts/:id — редактировать черновик (title, content, excerpt) router.patch('/:id', async (req, res) => { try { diff --git a/src/services/articles.js b/src/services/articles.js index ada0d45..824bb71 100644 --- a/src/services/articles.js +++ b/src/services/articles.js @@ -121,6 +121,16 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub const articleRes = await ai.generateArticle(blogChannel, { topic, keywords, customPrompt }); const content = articleRes.content; + // Guard: не сохраняем обрезанные/пустые генерации как черновик. + // Иначе в /admin/drafts появляются «огрызки» (особенно digest с веб-поиском, + // который иногда обрывается на середине). Бросаем — autogen пометит тему failed, + // стаб не создаётся, категория просто пропустит день. + const MIN_CONTENT_CHARS = 800; + const contentLen = (content || '').trim().length; + if (contentLen < MIN_CONTENT_CHARS) { + throw new Error(`generated content too short (${contentLen} < ${MIN_CONTENT_CHARS} chars) — skip, не сохраняю обрезок`); + } + // вытаскиваю title (первый H1 или первая строка) и excerpt const lines = content.split('\n').filter(Boolean); let title = topic; diff --git a/src/services/autogen.js b/src/services/autogen.js index 58a629e..33784e7 100644 --- a/src/services/autogen.js +++ b/src/services/autogen.js @@ -245,14 +245,28 @@ async function runAutogen({ forceCategory = null } = {}) { if (forceCategory) { settings = allSettings.filter(s => s.category === forceCategory); } else { - // Время окна ±5 мин уже применено в whereClause — переиспользуем - const { rows: timeFiltered } = await query( - `SELECT * FROM autogen_settings ${whereClause} ORDER BY run_hour, run_minute`, - params + // Атомарный «захват» категорий, у которых подошло время: одним UPDATE + // ставим last_run_at=NOW() и сразу получаем список захваченных. + // Тик идёт раз в минуту, окно сужено до ±2 мин. Конкурентные/соседние тики + // НЕ могут захватить одну категорию дважды: UPDATE берёт блокировку строки, + // а условие last_run_at < NOW()-6h отсекает повторный захват в тот же день. + // Это окончательно убирает дубли (раньше advisory-lock на пуле не работал, + // а last_run_at ставился только ПОСЛЕ медленной генерации ~60-120с). + // Фильтр ротации (4 из 8 категорий дня) применяем прямо в SQL. + const { rows: claimed } = await query( + `UPDATE autogen_settings + SET last_run_at = NOW() + WHERE enabled = true + AND run_hour = $1 + AND run_minute BETWEEN $2 AND $3 + AND (last_run_at IS NULL OR last_run_at < NOW() - INTERVAL '6 hours') + AND category = ANY($4::text[]) + RETURNING *`, + [currentHour, currentMinute - 2, currentMinute + 2, categoriesForToday] + ); + settings = claimed.sort( + (a, b) => (a.run_hour - b.run_hour) || (a.run_minute - b.run_minute) ); - // Оставляем только категории дня из сработавших по времени - const todaySet = new Set(categoriesForToday); - settings = timeFiltered.filter(s => todaySet.has(s.category)); } if (!settings.length) { diff --git a/src/services/promptBuilder.js b/src/services/promptBuilder.js index f2836dc..9b463a3 100644 --- a/src/services/promptBuilder.js +++ b/src/services/promptBuilder.js @@ -216,9 +216,15 @@ ${(persona.forbidden_phrases || []).map(f => `- "${f}"`).join('\n')} КРИТИЧЕСКИ ВАЖНО: - Пиши ОТ ПЕРВОГО ЛИЦА. Не "вы делаете" — а "я делаю". Не "стоит попробовать" — а "я попробовал". -- Начинай статью с МИНИ-ИСТОРИИ или конкретного наблюдения, а не с обобщения. - ХОРОШО: "На прошлой неделе я решил..." / "Сижу, отлаживаю..." / "Заметил странную штуку..." - ПЛОХО: "Есть такой тип задач..." / "Многие сталкиваются с..." / "В наше время..." +- Начинай статью с МИНИ-ИСТОРИИ, конкретной сцены, спорного утверждения или живого наблюдения — а не с обобщения. +- ВАЖНО про зачин: НЕ начинай статью с временно́го маркера. Фразы вида "Несколько лет назад...", "Примерно год назад...", "Года три назад...", "Прошлой осенью...", "Где-то в начале прошлого года...", "На прошлой неделе...", "Недавно..." — ЗАПРЕЩЕНЫ как первое предложение. Это стало шаблоном, по которому видно ИИ. Время можно упомянуть дальше по тексту, если оно правда нужно, но не как открывашку. +- Чередуй ТИПЫ зачинов от статьи к статье, не лепи один и тот же приём: + • сразу в действие/сцену: "Сижу, отлаживаю воркфлоу — и тут..." + • спорное/неожиданное утверждение: "RAG переоценён. По крайней мере в том виде, как его продают." + • конкретное наблюдение или факт: "Половина чат-ботов раздражают первой же фразой." + • вопрос самому себе или прямое обращение к проблеме: "Чем LangChain отличается от LlamaIndex — и почему это вообще должно тебя волновать?" + • маленькая деталь/реплика: "«Опять не собирается», — третий раз за утро." + ПЛОХО (так НЕ начинай): "Есть такой тип задач..." / "Многие сталкиваются с..." / "В наше время..." / любой вариант "...назад". - Используй "я", "мне", "у меня" регулярно. Если за абзац ни одного личного местоимения — переписывай. - Признавай ошибки: "сначала сделал не так", "запутался", "час потратил на ерунду", "оказалось всё проще". - Не называй статью "статьёй". Говори "пост", "разбор", "история", "заметка". @@ -273,7 +279,7 @@ ${personaSection} ═══════════════════════════════════════════════════════════ - Заголовок H1 — конкретный, без воды, без двоеточий-подзаголовков типа "ИИ: будущее уже наступило" -- Лид (1-2 абзаца) — сразу к делу, цепляешь читателя, ставишь проблему или интригу +- Лид (1-2 абзаца) — сразу к делу, цепляешь читателя, ставишь проблему или интригу. НЕ начинай первое предложение с временно́го маркера ("Несколько лет назад...", "Примерно год назад...", "Прошлой осенью...", "Недавно...", любое "...назад") — это приевшийся ИИ-шаблон. Чередуй типы зачинов: сцена, спорный тезис, конкретный факт, вопрос к читателю. - 3-5 разделов с H2 — каждый раздел развивает мысль, не дублирует - Заключение — короткое, без "подведём итоги". Можно вывод, наблюдение или открытый вопрос - 800-1500 слов