fix(autogen): устранить дубли черновиков и накопление backlog

- autogen: атомарный захват категорий (UPDATE ... RETURNING, last_run_at
  ставится ДО генерации), окно ±5 -> ±2 мин. Убирает дубли одной категории
  (advisory-lock на пуле не сериализовал, last_run_at ставился после ~90с гена).
- draftAutoApprove: дренаж backlog — 4 старейших черновика/день, не трогая
  сегодняшнюю свежую партию. Раньше брало строго 'вчера' + LIMIT 4, остальное
  копилось навсегда.
- articles: guard минимальной длины (800 симв) — не сохранять обрезки в draft
  (куцые digest с веб-поиском).
- drafts route: GET /api/drafts/:id с полным content (чтение статьи целиком).
- promptBuilder: запрет однообразных временных зачинов ('...назад').
This commit is contained in:
Alexey Pavlov
2026-06-30 00:58:01 +03:00
parent 4694093080
commit 4a11c63ed5
5 changed files with 67 additions and 19 deletions
+7 -6
View File
@@ -43,13 +43,15 @@ 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
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,7 +125,6 @@ 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
`);
if (parseInt(missed[0]?.cnt, 10) > 0) {
+17
View File
@@ -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 {
+10
View File
@@ -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;
+21 -7
View File
@@ -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) {
+10 -4
View File
@@ -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 слов