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
This commit is contained in:
Aleksei Pavlov
2026-06-19 10:52:22 +03:00
committed by Alexey Pavlov
parent 5b5f703078
commit 29788a8f9d
8 changed files with 1245 additions and 0 deletions
+34
View File
@@ -157,6 +157,36 @@ const migrate = async () => {
);
`);
// zero_notes — короткие заметки от AI-персонажа "Зеро"
// Программист с юмором, любит кофе. Постит 1 раз в день в 13:00 МСК.
// Поток: 13:00 генерится → вечером ручная проверка → 07:00 auto-approve → 13:00 публикация
await query(`
CREATE TABLE IF NOT EXISTS zero_notes (
id SERIAL PRIMARY KEY,
channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE,
content TEXT NOT NULL, -- 50-150 слов от первого лица
theme VARCHAR(500), -- о чём заметка (для логов и дедупа)
theme_bucket VARCHAR(50), -- ведро темы: bugs/tools/ai/coffee/musing/story/...
theme_hash VARCHAR(64), -- нормализованный хэш темы (sha256 первых 8 значимых слов)
pose VARCHAR(50), -- имя позы Зеро (zero-{pose}.webp)
image_url TEXT, -- /uploads/zero-{pose}.webp или внешний URL
status VARCHAR(20) DEFAULT 'draft', -- draft/approved/scheduled/published/failed/skipped
scheduled_at TIMESTAMPTZ, -- когда уйдёт в канал (13:00 МСК следующего дня)
approved_at TIMESTAMPTZ,
approved_by VARCHAR(100), -- 'auto' (07:00 cron) или email редактора
published_at TIMESTAMPTZ,
channel_message_id BIGINT, -- id сообщения в TG после публикации
tokens_in INTEGER,
tokens_out INTEGER,
model VARCHAR(100), -- какой моделью сгенерили
attempts INTEGER DEFAULT 0, -- сколько раз пытались опубликовать
error TEXT,
generation_meta JSONB DEFAULT '{}'::jsonb, -- доп. контекст: использованные триггеры позы, recentThemes hash и т.п.
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
`);
// series — тематические серии статей
await query(`
CREATE TABLE IF NOT EXISTS series (
@@ -185,6 +215,10 @@ const migrate = async () => {
CREATE INDEX IF NOT EXISTS idx_articles_status_pub ON articles(status, published_at DESC);
CREATE INDEX IF NOT EXISTS idx_notes_pub ON editor_notes(is_published, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_series_slug ON series(slug);
CREATE INDEX IF NOT EXISTS idx_zero_notes_status_sched ON zero_notes(status, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_zero_notes_channel ON zero_notes(channel_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_zero_notes_theme_hash ON zero_notes(theme_hash);
CREATE INDEX IF NOT EXISTS idx_zero_notes_published ON zero_notes(published_at DESC) WHERE status='published';
`);
console.log('[DB] Migrations applied');