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:
+9
-8
@@ -43,14 +43,16 @@ async function getTodaySlots() {
|
|||||||
|
|
||||||
async function runDraftAutoApprove() {
|
async function runDraftAutoApprove() {
|
||||||
try {
|
try {
|
||||||
// Берём черновики созданные ВЧЕРА по МСК (между 00:00 и 23:59 вчера).
|
// Берём самые СТАРЫЕ черновики, созданные НЕ сегодня (по МСК).
|
||||||
// Строго только вчерашние — чтобы при повторном деплое/рестарте не захватить
|
// Раньше окно было «строго вчера» + LIMIT 4 — поэтому всё, что не одобрили
|
||||||
// старые черновики и не создать очередь на несколько дней.
|
// на следующий день (дубли, излишки), оставалось в draft навсегда и копилось.
|
||||||
|
// Теперь дренируем backlog по 4 старейших в день, при этом сегодняшнюю свежую
|
||||||
|
// партию не трогаем (она станет доступна завтра). В связке с дедупом автогена
|
||||||
|
// (макс 4/день) это даёт устойчивое состояние без накопления.
|
||||||
const { rows: drafts } = await query(
|
const { rows: drafts } = await query(
|
||||||
`SELECT id, title, category, created_at FROM articles
|
`SELECT id, title, category, created_at FROM articles
|
||||||
WHERE status='draft'
|
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
|
ORDER BY created_at ASC
|
||||||
LIMIT 4`
|
LIMIT 4`
|
||||||
);
|
);
|
||||||
@@ -111,7 +113,7 @@ async function runDraftAutoApprove() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDraftAutoApproveScheduler() {
|
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 —
|
// Catch-up при старте: если сейчас уже после AUTO_APPROVE_HOUR_MSK —
|
||||||
// проверить есть ли вчерашние черновики которые пропустили тик.
|
// проверить есть ли вчерашние черновики которые пропустили тик.
|
||||||
@@ -123,8 +125,7 @@ function startDraftAutoApproveScheduler() {
|
|||||||
const { rows: missed } = await query(`
|
const { rows: missed } = await query(`
|
||||||
SELECT COUNT(*) AS cnt FROM articles
|
SELECT COUNT(*) AS cnt FROM articles
|
||||||
WHERE status='draft'
|
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) {
|
if (parseInt(missed[0]?.cnt, 10) > 0) {
|
||||||
console.log('[DraftApprove] catch-up при старте: найдены пропущенные вчерашние черновики (' + missed[0].cnt + ' шт)');
|
console.log('[DraftApprove] catch-up при старте: найдены пропущенные вчерашние черновики (' + missed[0].cnt + ' шт)');
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ router.get('/cover-styles', (req, res) => {
|
|||||||
res.json(COVER_STYLES.map(s => ({ id: s.name, name: s.name })));
|
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)
|
// PATCH /api/drafts/:id — редактировать черновик (title, content, excerpt)
|
||||||
router.patch('/:id', async (req, res) => {
|
router.patch('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -121,6 +121,16 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
|
|||||||
const articleRes = await ai.generateArticle(blogChannel, { topic, keywords, customPrompt });
|
const articleRes = await ai.generateArticle(blogChannel, { topic, keywords, customPrompt });
|
||||||
const content = articleRes.content;
|
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
|
// вытаскиваю title (первый H1 или первая строка) и excerpt
|
||||||
const lines = content.split('\n').filter(Boolean);
|
const lines = content.split('\n').filter(Boolean);
|
||||||
let title = topic;
|
let title = topic;
|
||||||
|
|||||||
+21
-7
@@ -245,14 +245,28 @@ async function runAutogen({ forceCategory = null } = {}) {
|
|||||||
if (forceCategory) {
|
if (forceCategory) {
|
||||||
settings = allSettings.filter(s => s.category === forceCategory);
|
settings = allSettings.filter(s => s.category === forceCategory);
|
||||||
} else {
|
} else {
|
||||||
// Время окна ±5 мин уже применено в whereClause — переиспользуем
|
// Атомарный «захват» категорий, у которых подошло время: одним UPDATE
|
||||||
const { rows: timeFiltered } = await query(
|
// ставим last_run_at=NOW() и сразу получаем список захваченных.
|
||||||
`SELECT * FROM autogen_settings ${whereClause} ORDER BY run_hour, run_minute`,
|
// Тик идёт раз в минуту, окно сужено до ±2 мин. Конкурентные/соседние тики
|
||||||
params
|
// НЕ могут захватить одну категорию дважды: 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) {
|
if (!settings.length) {
|
||||||
|
|||||||
@@ -216,9 +216,15 @@ ${(persona.forbidden_phrases || []).map(f => `- "${f}"`).join('\n')}
|
|||||||
|
|
||||||
КРИТИЧЕСКИ ВАЖНО:
|
КРИТИЧЕСКИ ВАЖНО:
|
||||||
- Пиши ОТ ПЕРВОГО ЛИЦА. Не "вы делаете" — а "я делаю". Не "стоит попробовать" — а "я попробовал".
|
- Пиши ОТ ПЕРВОГО ЛИЦА. Не "вы делаете" — а "я делаю". Не "стоит попробовать" — а "я попробовал".
|
||||||
- Начинай статью с МИНИ-ИСТОРИИ или конкретного наблюдения, а не с обобщения.
|
- Начинай статью с МИНИ-ИСТОРИИ, конкретной сцены, спорного утверждения или живого наблюдения — а не с обобщения.
|
||||||
ХОРОШО: "На прошлой неделе я решил..." / "Сижу, отлаживаю..." / "Заметил странную штуку..."
|
- ВАЖНО про зачин: НЕ начинай статью с временно́го маркера. Фразы вида "Несколько лет назад...", "Примерно год назад...", "Года три назад...", "Прошлой осенью...", "Где-то в начале прошлого года...", "На прошлой неделе...", "Недавно..." — ЗАПРЕЩЕНЫ как первое предложение. Это стало шаблоном, по которому видно ИИ. Время можно упомянуть дальше по тексту, если оно правда нужно, но не как открывашку.
|
||||||
ПЛОХО: "Есть такой тип задач..." / "Многие сталкиваются с..." / "В наше время..."
|
- Чередуй ТИПЫ зачинов от статьи к статье, не лепи один и тот же приём:
|
||||||
|
• сразу в действие/сцену: "Сижу, отлаживаю воркфлоу — и тут..."
|
||||||
|
• спорное/неожиданное утверждение: "RAG переоценён. По крайней мере в том виде, как его продают."
|
||||||
|
• конкретное наблюдение или факт: "Половина чат-ботов раздражают первой же фразой."
|
||||||
|
• вопрос самому себе или прямое обращение к проблеме: "Чем LangChain отличается от LlamaIndex — и почему это вообще должно тебя волновать?"
|
||||||
|
• маленькая деталь/реплика: "«Опять не собирается», — третий раз за утро."
|
||||||
|
ПЛОХО (так НЕ начинай): "Есть такой тип задач..." / "Многие сталкиваются с..." / "В наше время..." / любой вариант "...назад".
|
||||||
- Используй "я", "мне", "у меня" регулярно. Если за абзац ни одного личного местоимения — переписывай.
|
- Используй "я", "мне", "у меня" регулярно. Если за абзац ни одного личного местоимения — переписывай.
|
||||||
- Признавай ошибки: "сначала сделал не так", "запутался", "час потратил на ерунду", "оказалось всё проще".
|
- Признавай ошибки: "сначала сделал не так", "запутался", "час потратил на ерунду", "оказалось всё проще".
|
||||||
- Не называй статью "статьёй". Говори "пост", "разбор", "история", "заметка".
|
- Не называй статью "статьёй". Говори "пост", "разбор", "история", "заметка".
|
||||||
@@ -273,7 +279,7 @@ ${personaSection}
|
|||||||
═══════════════════════════════════════════════════════════
|
═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
- Заголовок H1 — конкретный, без воды, без двоеточий-подзаголовков типа "ИИ: будущее уже наступило"
|
- Заголовок H1 — конкретный, без воды, без двоеточий-подзаголовков типа "ИИ: будущее уже наступило"
|
||||||
- Лид (1-2 абзаца) — сразу к делу, цепляешь читателя, ставишь проблему или интригу
|
- Лид (1-2 абзаца) — сразу к делу, цепляешь читателя, ставишь проблему или интригу. НЕ начинай первое предложение с временно́го маркера ("Несколько лет назад...", "Примерно год назад...", "Прошлой осенью...", "Недавно...", любое "...назад") — это приевшийся ИИ-шаблон. Чередуй типы зачинов: сцена, спорный тезис, конкретный факт, вопрос к читателю.
|
||||||
- 3-5 разделов с H2 — каждый раздел развивает мысль, не дублирует
|
- 3-5 разделов с H2 — каждый раздел развивает мысль, не дублирует
|
||||||
- Заключение — короткое, без "подведём итоги". Можно вывод, наблюдение или открытый вопрос
|
- Заключение — короткое, без "подведём итоги". Можно вывод, наблюдение или открытый вопрос
|
||||||
- 800-1500 слов
|
- 800-1500 слов
|
||||||
|
|||||||
Reference in New Issue
Block a user