Initial RAA parser poster copy

This commit is contained in:
Your Name
2026-08-03 16:18:56 +05:00
commit d01890ff2c
148 changed files with 18924 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
CREATE TABLE IF NOT EXISTS admin_users (
id BIGSERIAL PRIMARY KEY,
login TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin' CHECK (role IN ('admin','editor','viewer')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS admin_sessions (
token_hash TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_user_id ON admin_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires_at ON admin_sessions(expires_at);
CREATE TABLE IF NOT EXISTS sources (
id BIGSERIAL PRIMARY KEY,
platform TEXT NOT NULL DEFAULT 'vk',
name TEXT NOT NULL,
url TEXT NOT NULL,
external_id TEXT,
external_owner_id BIGINT,
active BOOLEAN NOT NULL DEFAULT TRUE,
status TEXT NOT NULL DEFAULT 'new',
status_msg TEXT,
parse_from TIMESTAMPTZ,
last_checked_at TIMESTAMPTZ,
last_parsed_at TIMESTAMPTZ,
priority INTEGER NOT NULL DEFAULT 100,
tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
settings_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
archived_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_sources_platform_url_active
ON sources(platform, lower(url))
WHERE archived_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sources_active_platform ON sources(active, platform, priority, id)
WHERE archived_at IS NULL;
CREATE TABLE IF NOT EXISTS raw_posts (
id BIGSERIAL PRIMARY KEY,
source_id BIGINT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
platform TEXT NOT NULL,
external_post_id TEXT NOT NULL,
external_owner_id BIGINT,
original_url TEXT NOT NULL,
storage_owner_id BIGINT,
storage_post_id BIGINT,
storage_post_url TEXT,
raw_text TEXT NOT NULL DEFAULT '',
raw_json JSONB NOT NULL DEFAULT '{}'::jsonb,
text_hash TEXT NOT NULL,
content_hash TEXT NOT NULL,
posted_at TIMESTAMPTZ,
copied_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'raw_saved',
skip_reason TEXT,
error_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(source_id, external_post_id)
);
CREATE INDEX IF NOT EXISTS idx_raw_posts_status_created ON raw_posts(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_raw_posts_source_posted ON raw_posts(source_id, posted_at DESC);
CREATE INDEX IF NOT EXISTS idx_raw_posts_content_hash ON raw_posts(content_hash);
CREATE TABLE IF NOT EXISTS raw_post_media (
id BIGSERIAL PRIMARY KEY,
raw_post_id BIGINT NOT NULL REFERENCES raw_posts(id) ON DELETE CASCADE,
platform TEXT NOT NULL,
media_type TEXT NOT NULL CHECK (media_type IN ('photo','video','link','doc','unknown')),
original_url TEXT,
original_attachment_id TEXT,
storage_attachment_id TEXT,
storage_url TEXT,
preview_url TEXT,
width INTEGER,
height INTEGER,
duration_sec INTEGER,
sort_order INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_raw_post_media_post ON raw_post_media(raw_post_id, sort_order, id);
CREATE INDEX IF NOT EXISTS idx_raw_post_media_status ON raw_post_media(status);
CREATE TABLE IF NOT EXISTS jobs (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id BIGINT NOT NULL,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
next_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
locked_by TEXT,
locked_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_jobs_open_entity_type
ON jobs(type, entity_type, entity_id)
WHERE status IN ('pending','retry','in_progress');
CREATE INDEX IF NOT EXISTS idx_jobs_claim ON jobs(type, status, next_run_at, id);
CREATE TABLE IF NOT EXISTS worker_controls (
name TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
settings_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_by BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS worker_heartbeats (
name TEXT PRIMARY KEY,
heartbeat_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
current_job_id BIGINT,
meta_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value_json JSONB NOT NULL,
value_type TEXT NOT NULL DEFAULT 'str',
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'General',
updated_by BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
actor_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id BIGINT,
before_json JSONB,
after_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES
('vk-parser', TRUE, '{}'::jsonb),
('vk-storage-uploader', TRUE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('parser_interval_sec', '300'::jsonb, 'int', 'Parser interval, sec', 'Pause between parser cycles.', 'Parser'),
('parser_new_source_lookback_days', '14'::jsonb, 'int', 'New source lookback, days', 'How far back to read a new source.', 'Parser'),
('parser_reparse_overlap_minutes', '120'::jsonb, 'int', 'Reparse overlap, min', 'Overlap window for known sources.', 'Parser'),
('parser_min_text_length', '50'::jsonb, 'int', 'Minimum text length', 'Posts with shorter text are treated as junk.', 'Parser'),
('parser_skip_reposts', 'true'::jsonb, 'bool', 'Skip reposts', 'Do not save reposts from VK copy_history.', 'Parser'),
('parser_skip_empty_text', 'true'::jsonb, 'bool', 'Skip empty text posts', 'Treat posts without text as junk.', 'Parser'),
('parser_skip_no_media', 'true'::jsonb, 'bool', 'Skip posts without media', 'Treat posts without media as junk.', 'Parser'),
('parser_skip_text_too_short', 'true'::jsonb, 'bool', 'Skip short text posts', 'Treat posts shorter than parser_min_text_length as junk.', 'Parser'),
('parser_store_skipped_posts', 'false'::jsonb, 'bool', 'Store skipped posts', 'Keep junk posts in raw_posts instead of dropping them.', 'Parser'),
('parser_dedupe_content_hash', 'true'::jsonb, 'bool', 'Dedupe by content hash', 'Skip posts whose text and media hash already exists in raw_posts.', 'Parser'),
('parser_source_pause_sec', '0'::jsonb, 'float', 'Source pause, sec', 'Pause between VK sources inside one parser cycle.', 'Parser'),
('vk_requests_per_second', '3'::jsonb, 'int', 'VK requests per second', 'Global VK API rate limit per worker.', 'VK'),
('vk_wall_page_size', '50'::jsonb, 'int', 'VK wall page size', 'wall.get count per page.', 'VK'),
('vk_api_timeout_total_sec', '15'::jsonb, 'int', 'VK total timeout, sec', 'Total timeout for one VK API request.', 'VK'),
('vk_api_timeout_connect_sec', '5'::jsonb, 'int', 'VK connect timeout, sec', 'Connection timeout for VK API.', 'VK'),
('vk_rate_limit_sleep_sec', '1'::jsonb, 'float', 'VK rate limit sleep, sec', 'Sleep after VK API rate limit error code 6.', 'VK'),
('vk_api_retry_attempts', '3'::jsonb, 'int', 'VK retry attempts', 'Attempts for temporary VK API/network errors.', 'VK'),
('vk_api_retry_min_delay_sec', '2'::jsonb, 'float', 'VK retry min delay, sec', 'Initial retry delay for temporary VK errors.', 'VK'),
('vk_api_retry_max_delay_sec', '10'::jsonb, 'float', 'VK retry max delay, sec', 'Maximum retry delay for temporary VK errors.', 'VK'),
('uploader_interval_sec', '5'::jsonb, 'int', 'Uploader idle interval, sec', 'Pause when no jobs are available.', 'Uploader'),
('uploader_download_timeout_sec', '45'::jsonb, 'int', 'Download timeout, sec', 'Timeout for source media download.', 'Uploader')
ON CONFLICT (key) DO NOTHING;
+27
View File
@@ -0,0 +1,27 @@
ALTER TABLE raw_posts
ADD COLUMN IF NOT EXISTS tg_storage_chat_id BIGINT,
ADD COLUMN IF NOT EXISTS tg_storage_thread_id BIGINT,
ADD COLUMN IF NOT EXISTS tg_storage_message_ids BIGINT[] NOT NULL DEFAULT ARRAY[]::BIGINT[],
ADD COLUMN IF NOT EXISTS tg_storage_meta_message_id BIGINT;
ALTER TABLE raw_post_media
ADD COLUMN IF NOT EXISTS tg_file_id TEXT,
ADD COLUMN IF NOT EXISTS tg_file_unique_id TEXT;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('tg_media_channel_id', '""'::jsonb, 'str', 'Telegram media storage chat', 'Chat id or chat_id:topic_id for raw media storage.', 'Telegram'),
('local_bot_api_url', '""'::jsonb, 'str', 'Local Telegram Bot API URL', 'Optional local Bot API server URL.', 'Telegram'),
('telegram_media_group_max_items', '10'::jsonb, 'int', 'Telegram media group max items', 'Maximum media items per Telegram media group.', 'Telegram'),
('telegram_media_upload_delay_sec', '1'::jsonb, 'float', 'Telegram media upload delay, sec', 'Pause between Telegram upload operations.', 'Telegram'),
('telegram_retry_max_attempts', '4'::jsonb, 'int', 'Telegram retry attempts', 'Retry attempts for Telegram calls.', 'Telegram'),
('telegram_retry_backoff_max_sec', '15'::jsonb, 'int', 'Telegram retry max backoff, sec', 'Maximum retry backoff for Telegram calls.', 'Telegram'),
('telegram_caption_limit', '1024'::jsonb, 'int', 'Telegram caption limit', 'Caption character limit for media messages.', 'Telegram'),
('telegram_message_limit', '4096'::jsonb, 'int', 'Telegram message limit', 'Text message character limit.', 'Telegram'),
('telegram_text_overflow_marker', '"Продолжение следующим сообщением."'::jsonb, 'str', 'Telegram overflow marker', 'Caption marker when text does not fit.', 'Telegram'),
('media_post_job_pause_sec', '0.2'::jsonb, 'float', 'Post job pause, sec', 'Pause after each processed media storage job.', 'Uploader'),
('video_max_size_mb', '49'::jsonb, 'int', 'Video max size, MB', 'Maximum video size to upload to Telegram.', 'Uploader'),
('video_max_duration_sec', '300'::jsonb, 'int', 'Video max duration, sec', 'Maximum video duration to upload to Telegram.', 'Uploader'),
('uploader_yt_dlp_timeout_sec', '300'::jsonb, 'int', 'yt-dlp timeout, sec', 'Timeout for VK video downloads.', 'Uploader'),
('uploader_max_media_attempts', '3'::jsonb, 'int', 'Media max attempts', 'Maximum attempts per media item.', 'Uploader')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,16 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('parser_skip_reposts', 'true'::jsonb, 'bool', 'Skip reposts', 'Do not save reposts from VK copy_history.', 'Parser'),
('parser_skip_empty_text', 'true'::jsonb, 'bool', 'Skip empty text posts', 'Treat posts without text as junk.', 'Parser'),
('parser_skip_no_media', 'true'::jsonb, 'bool', 'Skip posts without media', 'Treat posts without media as junk.', 'Parser'),
('parser_skip_text_too_short', 'true'::jsonb, 'bool', 'Skip short text posts', 'Treat posts shorter than parser_min_text_length as junk.', 'Parser'),
('parser_store_skipped_posts', 'false'::jsonb, 'bool', 'Store skipped posts', 'Keep junk posts in raw_posts instead of dropping them.', 'Parser'),
('parser_dedupe_content_hash', 'true'::jsonb, 'bool', 'Dedupe by content hash', 'Skip posts whose text and media hash already exists in raw_posts.', 'Parser'),
('parser_source_pause_sec', '0'::jsonb, 'float', 'Source pause, sec', 'Pause between VK sources inside one parser cycle.', 'Parser'),
('vk_api_timeout_total_sec', '15'::jsonb, 'int', 'VK total timeout, sec', 'Total timeout for one VK API request.', 'VK'),
('vk_api_timeout_connect_sec', '5'::jsonb, 'int', 'VK connect timeout, sec', 'Connection timeout for VK API.', 'VK'),
('vk_rate_limit_sleep_sec', '1'::jsonb, 'float', 'VK rate limit sleep, sec', 'Sleep after VK API rate limit error code 6.', 'VK'),
('vk_api_retry_attempts', '3'::jsonb, 'int', 'VK retry attempts', 'Attempts for temporary VK API/network errors.', 'VK'),
('vk_api_retry_min_delay_sec', '2'::jsonb, 'float', 'VK retry min delay, sec', 'Initial retry delay for temporary VK errors.', 'VK'),
('vk_api_retry_max_delay_sec', '10'::jsonb, 'float', 'VK retry max delay, sec', 'Maximum retry delay for temporary VK errors.', 'VK')
ON CONFLICT (key) DO NOTHING;
+28
View File
@@ -0,0 +1,28 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('parser_skip_empty_text', 'true'::jsonb, 'bool', 'Skip empty text posts', 'Treat posts without text as junk.', 'Parser')
ON CONFLICT (key) DO NOTHING;
UPDATE app_settings
SET value_json='50'::jsonb,
description='Posts with shorter text are treated as junk.',
updated_at=NOW()
WHERE key='parser_min_text_length'
AND value_json='0'::jsonb;
UPDATE app_settings
SET value_json='false'::jsonb,
description='Keep junk posts in raw_posts instead of dropping them.',
updated_at=NOW()
WHERE key='parser_store_skipped_posts'
AND value_json='true'::jsonb;
UPDATE app_settings
SET description='Treat posts without media as junk.',
updated_at=NOW()
WHERE key='parser_skip_no_media';
UPDATE app_settings
SET description='Treat posts shorter than parser_min_text_length as junk.',
updated_at=NOW()
WHERE key='parser_skip_text_too_short';
+50
View File
@@ -0,0 +1,50 @@
ALTER TABLE raw_posts
ADD COLUMN IF NOT EXISTS qualification_status TEXT,
ADD COLUMN IF NOT EXISTS qualification_score INTEGER,
ADD COLUMN IF NOT EXISTS qualification_decision TEXT,
ADD COLUMN IF NOT EXISTS qualification_reason TEXT,
ADD COLUMN IF NOT EXISTS qualification_model TEXT,
ADD COLUMN IF NOT EXISTS qualification_prompt_hash TEXT,
ADD COLUMN IF NOT EXISTS qualification_batch_id BIGINT,
ADD COLUMN IF NOT EXISTS qualified_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_raw_posts_qualification_claim
ON raw_posts(status, qualification_status, created_at)
WHERE status='storage_ready';
CREATE TABLE IF NOT EXISTS ai_qualification_batches (
id BIGSERIAL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'processing',
provider TEXT NOT NULL,
model TEXT NOT NULL,
prompt_hash TEXT NOT NULL,
posts_count INTEGER NOT NULL DEFAULT 0,
accepted_count INTEGER NOT NULL DEFAULT 0,
rejected_count INTEGER NOT NULL DEFAULT 0,
maybe_count INTEGER NOT NULL DEFAULT 0,
request_json JSONB NOT NULL DEFAULT '{}'::jsonb,
response_json JSONB,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('ai-qualifier', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('ai_qualifier_enabled', 'false'::jsonb, 'bool', 'AI qualifier enabled', 'Enable qualification requests to AI API.', 'AI Qualifier'),
('ai_qualifier_provider', '"openrouter"'::jsonb, 'str', 'Provider', 'LiteLLM provider: openrouter, openai, anthropic, gemini, openai_compatible.', 'AI Qualifier'),
('ai_qualifier_model', '"openrouter/openai/gpt-4.1-mini"'::jsonb, 'str', 'Model', 'Model name in LiteLLM format.', 'AI Qualifier'),
('ai_qualifier_api_key', '""'::jsonb, 'secret', 'API key', 'AI provider API key.', 'AI Qualifier'),
('ai_qualifier_api_base', '""'::jsonb, 'str', 'API base', 'Optional base URL for OpenAI-compatible providers.', 'AI Qualifier'),
('ai_qualifier_batch_size', '30'::jsonb, 'int', 'Batch size', 'How many raw posts to classify in one request.', 'AI Qualifier'),
('ai_qualifier_min_score', '7'::jsonb, 'int', 'Minimum accepted score', 'Posts with score >= this value are accepted.', 'AI Qualifier'),
('ai_qualifier_interval_sec', '60'::jsonb, 'int', 'Idle interval, sec', 'Pause between qualifier checks.', 'AI Qualifier'),
('ai_qualifier_max_text_chars', '2000'::jsonb, 'int', 'Max text chars per post', 'Trim raw text before sending it to AI.', 'AI Qualifier'),
('ai_qualifier_temperature', '0'::jsonb, 'float', 'Temperature', 'AI sampling temperature for stable classification.', 'AI Qualifier'),
('ai_qualifier_timeout_sec', '120'::jsonb, 'int', 'Timeout, sec', 'AI request timeout.', 'AI Qualifier'),
('ai_qualifier_prompt', '"Ты квалификатор постов для проекта, который собирает посты производителей и магазинов милитари, тактической и airsoft-снаряги.\\n\\nОцени каждый raw-пост по пригодности для дальнейшего рерайта и публикации.\\n\\nПодходит: товарные посты, новинки, обзоры, производство, экипировка, тактическая одежда, броня, подсумки, рюкзаки, разгрузки, ремни, патчи, аксессуары, airsoft/страйкбольная снаряга, полезные анонсы производителя.\\n\\nМусор: мемы, поздравления, вакансии, политика, бытовуха, пустые посты, посты без конкретного товара/снаряжения, розыгрыши без предметного контента, чистая реклама скидки без понятного товара, нерелевантные новости.\\n\\nВерни только JSON-объект по схеме: {\\\"results\\\":[{\\\"id\\\":123,\\\"score\\\":8,\\\"decision\\\":\\\"accepted\\\",\\\"reason\\\":\\\"короткая причина\\\"}]}. score — целое число от 1 до 10. decision: accepted, rejected или maybe. Не добавляй markdown и пояснения вне JSON."'::jsonb, 'text', 'Prompt', 'Main qualification prompt.', 'AI Qualifier')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,47 @@
UPDATE app_settings SET title='Интервал парсера, сек', description='Пауза между полными циклами парсинга источников.' WHERE key='parser_interval_sec';
UPDATE app_settings SET title='Глубина нового источника, дней', description='Сколько дней назад читать посты при первом запуске источника.' WHERE key='parser_new_source_lookback_days';
UPDATE app_settings SET title='Перекрытие повторного чтения, мин', description='Насколько назад перечитывать источник, чтобы не пропустить посты.' WHERE key='parser_reparse_overlap_minutes';
UPDATE app_settings SET title='Минимальная длина текста', description='Посты с текстом короче этого значения считаются мусором.' WHERE key='parser_min_text_length';
UPDATE app_settings SET title='Пропускать репосты', description='Не брать VK-посты с copy_history.' WHERE key='parser_skip_reposts';
UPDATE app_settings SET title='Пропускать пустой текст', description='Не брать посты без текста.' WHERE key='parser_skip_empty_text';
UPDATE app_settings SET title='Пропускать без медиа', description='Не брать посты без фото/видео.' WHERE key='parser_skip_no_media';
UPDATE app_settings SET title='Пропускать короткий текст', description='Не брать посты короче минимальной длины текста.' WHERE key='parser_skip_text_too_short';
UPDATE app_settings SET title='Сохранять мусорные посты', description='Если включено, мусорные посты попадут в raw_posts со статусом skipped. Обычно выключено.' WHERE key='parser_store_skipped_posts';
UPDATE app_settings SET title='Дедупликация по контенту', description='Не брать пост, если такой текст и набор медиа уже есть в raw_posts.' WHERE key='parser_dedupe_content_hash';
UPDATE app_settings SET title='Пауза между источниками, сек', description='Задержка между чтением разных VK-источников внутри одного цикла.' WHERE key='parser_source_pause_sec';
UPDATE app_settings SET title='VK запросов в секунду', description='Ограничение частоты запросов к VK API.' WHERE key='vk_requests_per_second';
UPDATE app_settings SET title='Размер страницы VK wall.get', description='Сколько постов запрашивать за один вызов wall.get.' WHERE key='vk_wall_page_size';
UPDATE app_settings SET title='VK общий таймаут, сек', description='Общий таймаут одного запроса к VK API.' WHERE key='vk_api_timeout_total_sec';
UPDATE app_settings SET title='VK таймаут соединения, сек', description='Таймаут установки соединения с VK API.' WHERE key='vk_api_timeout_connect_sec';
UPDATE app_settings SET title='Пауза при VK rate limit, сек', description='Сколько ждать после ошибки VK API error_code=6.' WHERE key='vk_rate_limit_sleep_sec';
UPDATE app_settings SET title='VK retry попытки', description='Сколько раз повторять временно упавший VK-запрос.' WHERE key='vk_api_retry_attempts';
UPDATE app_settings SET title='VK retry минимум, сек', description='Начальная задержка между повторными VK-запросами.' WHERE key='vk_api_retry_min_delay_sec';
UPDATE app_settings SET title='VK retry максимум, сек', description='Максимальная задержка между повторными VK-запросами.' WHERE key='vk_api_retry_max_delay_sec';
UPDATE app_settings SET title='Интервал аплоадера, сек', description='Пауза, когда нет задач на загрузку raw-постов в Telegram.' WHERE key='uploader_interval_sec';
UPDATE app_settings SET title='Таймаут скачивания, сек', description='Таймаут скачивания исходного медиа.' WHERE key='uploader_download_timeout_sec';
UPDATE app_settings SET title='Telegram канал raw', description='ID Telegram-канала/топика для хранения raw-постов.' WHERE key='tg_media_channel_id';
UPDATE app_settings SET title='Локальный Bot API URL', description='Опциональный URL локального Telegram Bot API сервера.' WHERE key='local_bot_api_url';
UPDATE app_settings SET title='Задержка на медиа, сек', description='Пауза после отправки медиа, умножается на количество файлов.' WHERE key='media_upload_delay_sec';
UPDATE app_settings SET title='Пауза после поста, сек', description='Короткая пауза после полной обработки одного raw-поста.' WHERE key='media_post_job_pause_sec';
UPDATE app_settings SET title='Максимум медиа в группе', description='Максимум файлов в одном Telegram media group.' WHERE key='media_group_max_items';
UPDATE app_settings SET title='Максимум попыток медиа', description='Сколько раз пытаться скачать/загрузить отдельное медиа.' WHERE key='max_media_attempts';
UPDATE app_settings SET title='Telegram retry попытки', description='Сколько раз повторять временно упавший Telegram-запрос.' WHERE key='tg_retry_attempts';
UPDATE app_settings SET title='Telegram backoff максимум, сек', description='Максимальная задержка между retry Telegram-запросов.' WHERE key='tg_retry_backoff_max_sec';
UPDATE app_settings SET title='Максимум видео, МБ', description='Максимальный размер видео для загрузки в Telegram.' WHERE key='video_max_size_mb';
UPDATE app_settings SET title='Максимум видео, сек', description='Видео длиннее этого значения помечается как link_only.' WHERE key='video_max_duration_sec';
UPDATE app_settings SET title='yt-dlp таймаут, сек', description='Сколько ждать скачивания VK-видео через yt-dlp.' WHERE key='uploader_yt_dlp_timeout_sec';
UPDATE app_settings SET title='Включить AI-квалификатор', description='Разрешить воркеру отправлять raw-посты в AI API.' WHERE key='ai_qualifier_enabled';
UPDATE app_settings SET title='Провайдер LLM', description='Провайдер для LiteLLM. Для OpenRouter список моделей подгружается live.' WHERE key='ai_qualifier_provider';
UPDATE app_settings SET title='Модель LLM', description='Модель для квалификации. Можно выбрать из списка или вписать вручную.' WHERE key='ai_qualifier_model';
UPDATE app_settings SET title='API токен', description='Токен выбранного LLM-провайдера. Сохраняется в настройках.' WHERE key='ai_qualifier_api_key';
UPDATE app_settings SET title='API base URL', description='Опциональный base URL для OpenAI-compatible провайдера.' WHERE key='ai_qualifier_api_base';
UPDATE app_settings SET title='Размер пачки', description='Сколько raw-постов отправлять в один AI-запрос.' WHERE key='ai_qualifier_batch_size';
UPDATE app_settings SET title='Минимальная оценка', description='Пост принимается, если score больше или равен этому значению.' WHERE key='ai_qualifier_min_score';
UPDATE app_settings SET title='Интервал проверки, сек', description='Пауза между проверками новых raw-постов.' WHERE key='ai_qualifier_interval_sec';
UPDATE app_settings SET title='Максимум символов текста', description='Сколько символов raw_text отправлять в AI на один пост.' WHERE key='ai_qualifier_max_text_chars';
UPDATE app_settings SET title='Температура', description='Температура генерации. Для классификации обычно 0.' WHERE key='ai_qualifier_temperature';
UPDATE app_settings SET title='AI таймаут, сек', description='Таймаут одного AI-запроса.' WHERE key='ai_qualifier_timeout_sec';
UPDATE app_settings SET title='Промпт квалификатора', description='Основная инструкция для оценки пригодности raw-постов.' WHERE key='ai_qualifier_prompt';
+57
View File
@@ -0,0 +1,57 @@
ALTER TABLE ai_qualification_batches
ADD COLUMN IF NOT EXISTS prompt_tokens INTEGER,
ADD COLUMN IF NOT EXISTS completion_tokens INTEGER,
ADD COLUMN IF NOT EXISTS total_tokens INTEGER,
ADD COLUMN IF NOT EXISTS estimated_cost_usd NUMERIC(12, 6);
ALTER TABLE raw_posts
ADD COLUMN IF NOT EXISTS rewrite_status TEXT,
ADD COLUMN IF NOT EXISTS rewritten_text TEXT,
ADD COLUMN IF NOT EXISTS rewrite_notes TEXT,
ADD COLUMN IF NOT EXISTS rewrite_model TEXT,
ADD COLUMN IF NOT EXISTS rewrite_prompt_hash TEXT,
ADD COLUMN IF NOT EXISTS rewrite_batch_id BIGINT,
ADD COLUMN IF NOT EXISTS rewritten_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_raw_posts_writer_claim
ON raw_posts(qualification_status, rewrite_status, qualified_at, id)
WHERE qualification_status='accepted';
CREATE TABLE IF NOT EXISTS ai_writer_batches (
id BIGSERIAL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'processing',
provider TEXT NOT NULL,
model TEXT NOT NULL,
prompt_hash TEXT NOT NULL,
posts_count INTEGER NOT NULL DEFAULT 0,
ready_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
request_json JSONB NOT NULL DEFAULT '{}'::jsonb,
response_json JSONB,
error TEXT,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
estimated_cost_usd NUMERIC(12, 6),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('ai-writer', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('ai_writer_enabled', 'false'::jsonb, 'bool', 'AI-райтер включён', 'Разрешает отправлять прошедшие квалификацию посты на рерайт.', 'AI Writer'),
('ai_writer_provider', '"anthropic"'::jsonb, 'str', 'Провайдер LLM', 'Через какого провайдера LiteLLM отправлять запросы райтера.', 'AI Writer'),
('ai_writer_model', '"claude-haiku-4-5-20251001"'::jsonb, 'str', 'Модель', 'Модель для рерайта. Для отладки используем дешёвую Haiku.', 'AI Writer'),
('ai_writer_api_key', '""'::jsonb, 'secret', 'API-ключ', 'Ключ провайдера для AI-райтера. Можно использовать тот же Anthropic-ключ, что и у квалификатора.', 'AI Writer'),
('ai_writer_api_base', '""'::jsonb, 'str', 'API base URL', 'Необязательный base URL для OpenAI-compatible провайдеров.', 'AI Writer'),
('ai_writer_prompt', '"Ты редактор Telegram/VK-канала про милитари, тактическую и airsoft-снарягу. Перепиши исходный пост производителя в готовый к публикации пост на русском языке.\\n\\nСтиль: живой, понятный, без канцелярита, без чрезмерной рекламы, без выдуманных фактов. Сохраняй конкретику: модель, материал, цвета, размеры, назначение, цену и условия, если они есть в исходнике. Не добавляй то, чего нет в исходном посте.\\n\\nФормат результата: 1 короткий заголовок первой строкой, затем 2-5 абзацев или аккуратный список, если исходник товарный. В конце не добавляй ссылки и хэштеги.\\n\\nВерни только JSON-объект по схеме: {\\\"rewrites\\\":[{\\\"id\\\":123,\\\"text\\\":\\\"готовый текст\\\",\\\"notes\\\":\\\"короткая заметка для редактора\\\"}]}. Без markdown вне JSON."'::jsonb, 'text', 'Промпт райтера', 'Главный промпт рерайта. Поддерживает обычные переносы строк.', 'AI Writer'),
('ai_writer_batch_size', '1'::jsonb, 'int', 'Размер batch', 'Сколько постов переписывать за один запрос. Для отладки лучше 1.', 'AI Writer'),
('ai_writer_max_text_chars', '3500'::jsonb, 'int', 'Максимум символов исходника', 'Обрезает исходный raw-текст перед отправкой в LLM.', 'AI Writer'),
('ai_writer_temperature', '0.4'::jsonb, 'float', 'Температура генерации', 'Ниже — стабильнее и суше, выше — свободнее и разнообразнее.', 'AI Writer'),
('ai_writer_timeout_sec', '180'::jsonb, 'int', 'Таймаут, сек', 'Сколько ждать ответ модели.', 'AI Writer'),
('ai_writer_interval_sec', '60'::jsonb, 'int', 'Интервал ожидания, сек', 'Пауза между проверками очереди writer, когда работы нет.', 'AI Writer')
ON CONFLICT (key) DO NOTHING;
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE ai_qualification_batches
ADD COLUMN IF NOT EXISTS prompt_text TEXT;
ALTER TABLE ai_writer_batches
ADD COLUMN IF NOT EXISTS prompt_text TEXT;
@@ -0,0 +1,6 @@
ALTER TABLE raw_posts
ADD COLUMN IF NOT EXISTS qualification_reject_tag TEXT;
CREATE INDEX IF NOT EXISTS idx_raw_posts_qualification_reject_tag
ON raw_posts(qualification_reject_tag)
WHERE qualification_reject_tag IS NOT NULL;
@@ -0,0 +1,53 @@
ALTER TABLE sources
ADD COLUMN IF NOT EXISTS tag TEXT;
UPDATE sources
SET tag = lower(regexp_replace(COALESCE(NULLIF(external_id, ''), name), '[^[:alnum:]_]+', '_', 'g'))
WHERE tag IS NULL OR btrim(tag) = '';
ALTER TABLE raw_posts
ADD COLUMN IF NOT EXISTS rewrite_category TEXT,
ADD COLUMN IF NOT EXISTS rewrite_category_tag TEXT,
ADD COLUMN IF NOT EXISTS rewrite_source_tag TEXT;
CREATE INDEX IF NOT EXISTS idx_sources_tag ON sources(lower(tag))
WHERE archived_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_raw_posts_rewrite_category ON raw_posts(rewrite_category)
WHERE rewrite_category IS NOT NULL;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('ai_writer_categories', '["защита","одежда","разгрузка","рюкзаки","airsoft","патчи","электроника","аксессуары","производство"]'::jsonb, 'text', 'Категории райтера', 'Список категорий, из которых AI-райтер выбирает одну. Можно писать через запятую или с новой строки.', 'AI Writer')
ON CONFLICT (key) DO NOTHING;
UPDATE app_settings
SET value_json = to_jsonb($prompt$You are an editor of a Russian Telegram channel about military, tactical and airsoft gear.
Write on behalf of the channel — as an expert observer, not the manufacturer.
Source posts come from manufacturers/shops; your job is to reframe them as channel news.
VOICE: concise, expert, matter-of-fact. Like telling a knowledgeable friend what dropped.
Example opener framing: "У [Producer] вышел...", "Wartech показали...", "[Producer] добавили в линейку..."
RULES:
- Use only facts from source text. Never invent specs, properties or claims.
- If source is thin — write a short post, do not pad it.
- No hype, no exclamation spam, no manufacturer PR tone.
- Post length: 3-6 lines. Short is better than padded.
- No bullet lists unless source has 4+ distinct specs worth listing.
- Do not add hashtags. The system will add category and producer hashtags separately.
CATEGORIES: pick exactly one from the categories field in input.
INPUT fields used: id, producer_name, producer_tag, text.
qualification_score and media_count are metadata; ignore them for rewrite unless they help understand context.
OUTPUT: JSON only, no markdown outside JSON.$prompt$::text),
description = 'Главный промпт рерайта. Модель выбирает category, но не пишет хэштеги.',
updated_at = NOW()
WHERE key = 'ai_writer_prompt'
AND (
value_json::text ILIKE '%хэштег%'
OR value_json::text ILIKE '%hashtag%'
OR value_json::text ILIKE '%#CATEGORY%'
);
+39
View File
@@ -0,0 +1,39 @@
UPDATE app_settings
SET title='Инструкция квалификатора',
description='Редактируемая человеческая инструкция: критерии оценки и мусора. JSON-контракт ответа добавляется кодом автоматически.'
WHERE key='ai_qualifier_prompt';
UPDATE app_settings
SET title='Инструкция райтера',
description='Редактируемая человеческая инструкция: стиль, факты, длина и голос канала. Категорию и JSON-контракт ответа контролирует код.'
WHERE key='ai_writer_prompt';
UPDATE app_settings
SET description='Модель для квалификации. Список берётся live из API провайдера, если доступен ключ; fallback помечается отдельно.'
WHERE key='ai_qualifier_model';
UPDATE app_settings
SET description='Модель для рерайта. Список берётся live из API провайдера, если доступен ключ; fallback помечается отдельно.'
WHERE key='ai_writer_model';
UPDATE app_settings
SET description='Токен выбранного LLM-провайдера. В админке показывается полностью, поэтому доступ к настройкам должен быть только у доверенных пользователей.'
WHERE key IN ('ai_qualifier_api_key', 'ai_writer_api_key');
UPDATE app_settings m
SET value_json = to_jsonb('anthropic/' || trim(both '"' from m.value_json::text))
FROM app_settings p
WHERE p.key = replace(m.key, '_model', '_provider')
AND p.value_json = '"anthropic"'::jsonb
AND m.key IN ('ai_qualifier_model', 'ai_writer_model')
AND m.value_json::text NOT LIKE '"anthropic/%'
AND m.value_json::text NOT LIKE '"%/%"';
UPDATE app_settings m
SET value_json = to_jsonb('gemini/' || trim(both '"' from m.value_json::text))
FROM app_settings p
WHERE p.key = replace(m.key, '_model', '_provider')
AND p.value_json = '"gemini"'::jsonb
AND m.key IN ('ai_qualifier_model', 'ai_writer_model')
AND m.value_json::text NOT LIKE '"gemini/%'
AND m.value_json::text NOT LIKE '"%/%"';
@@ -0,0 +1,15 @@
ALTER TABLE raw_posts
ADD COLUMN IF NOT EXISTS qualification_model_decision TEXT;
UPDATE raw_posts rp
SET qualification_model_decision = item->>'decision'
FROM ai_qualification_batches b,
jsonb_array_elements(COALESCE(b.response_json->'results', '[]'::jsonb)) AS item
WHERE rp.qualification_batch_id = b.id
AND (item->>'id')::bigint = rp.id
AND rp.qualification_model_decision IS NULL
AND item ? 'decision';
CREATE INDEX IF NOT EXISTS idx_raw_posts_qualification_model_decision
ON raw_posts(qualification_model_decision)
WHERE qualification_model_decision IS NOT NULL;
+32
View File
@@ -0,0 +1,32 @@
ALTER TABLE raw_posts
ADD COLUMN IF NOT EXISTS editorial_status TEXT,
ADD COLUMN IF NOT EXISTS final_text TEXT,
ADD COLUMN IF NOT EXISTS final_category TEXT,
ADD COLUMN IF NOT EXISTS final_category_tag TEXT,
ADD COLUMN IF NOT EXISTS final_source_tag TEXT,
ADD COLUMN IF NOT EXISTS editor_notes TEXT,
ADD COLUMN IF NOT EXISTS regeneration_prompt TEXT,
ADD COLUMN IF NOT EXISTS reviewed_by BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
UPDATE raw_posts
SET editorial_status = 'review'
WHERE editorial_status IS NULL
AND rewrite_status = 'ready';
UPDATE raw_posts
SET final_text = rewritten_text,
final_category = rewrite_category,
final_category_tag = rewrite_category_tag,
final_source_tag = rewrite_source_tag
WHERE rewrite_status = 'ready'
AND final_text IS NULL;
CREATE INDEX IF NOT EXISTS idx_raw_posts_editorial_status
ON raw_posts(editorial_status, rewritten_at DESC, id)
WHERE rewrite_status = 'ready';
CREATE INDEX IF NOT EXISTS idx_raw_posts_final_category
ON raw_posts(final_category)
WHERE final_category IS NOT NULL;
@@ -0,0 +1,43 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
(
'ai_qualifier_contract',
'"OUTPUT SCHEMA (return array matching input order):\n{\"results\":[{\"id\":123,\"score\":8,\"decision\":\"accepted\",\"reason\":\"до 10 слов на русском\",\"reject_tag\":null}]}\n\nRules:\n- Input is a JSON array of posts.\n- Return JSON only. No markdown. No text outside JSON.\n- score must be integer 1..10.\n- decision must be exactly one of: \"accepted\", \"rejected\", \"maybe\".\n- reject_tag (rejected/maybe only) must be one of: \"meme\", \"no_product\", \"politics\", \"discount_only\", \"off_topic\", \"vacancy\", \"low_content\", \"wrong_language\", \"injection\", \"weapon\", null.\n- Return one result for every input post id."'::jsonb,
'text',
'Технический контракт квалификатора',
'JSON-схема и строгие правила ответа. Обычно не меняется при правке смыслового промпта.',
'AI Qualifier'
),
(
'ai_writer_contract',
'"OUTPUT SCHEMA (return array matching input order):\n{\"rewrites\":[{\"id\":123,\"category\":\"разгрузка\",\"text\":\"готовый текст без хэштегов\",\"notes\":\"короткая заметка для редактора или null\"}]}\n\nRules:\n- Input is a JSON object with key \"posts\" containing accepted posts.\n- Each post includes producer_name and producer_tag. Use producer_name when it helps, but do not invent facts.\n- If post includes editor_regeneration_prompt, follow it as an additional editor instruction while still obeying facts and output schema.\n- Do not add hashtags to rewritten text. Pick exactly one category from the categories list in input.\n- Return JSON only. No markdown. No text outside JSON.\n- Return one rewrite for every input post id."'::jsonb,
'text',
'Технический контракт райтера',
'JSON-схема, выбор категории и запрет хэштегов в тексте. Обычно не меняется при правке стиля.',
'AI Writer'
)
ON CONFLICT (key) DO NOTHING;
UPDATE app_settings
SET title='Свободная инструкция квалификатора',
description='Роль, тон, критерии и примеры оценки. Технический JSON-контракт вынесен отдельно.'
WHERE key='ai_qualifier_prompt';
UPDATE app_settings
SET title='Свободная инструкция райтера',
description='Стиль, голос канала, правила фактов и примеры текста. Технический JSON-контракт вынесен отдельно.'
WHERE key='ai_writer_prompt';
CREATE TABLE IF NOT EXISTS admin_login_attempts (
id BIGSERIAL PRIMARY KEY,
ip TEXT NOT NULL,
login TEXT NOT NULL DEFAULT '',
success BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_admin_login_attempts_ip_time
ON admin_login_attempts(ip, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_admin_login_attempts_login_time
ON admin_login_attempts(login, created_at DESC);
+9
View File
@@ -0,0 +1,9 @@
ALTER TABLE raw_post_media
ADD COLUMN IF NOT EXISTS editor_hidden BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS editor_added BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS editor_uploaded_by BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS editor_uploaded_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_raw_post_media_editor_visible
ON raw_post_media(raw_post_id, sort_order, id)
WHERE editor_hidden = FALSE;
@@ -0,0 +1,54 @@
UPDATE raw_posts
SET rewrite_status='ready',
editorial_status='review',
regeneration_prompt=NULL,
updated_at=NOW()
WHERE COALESCE(editorial_status, '')='regenerating';
UPDATE app_settings
SET value_json = to_jsonb($prompt$
Ты редактор русского Telegram-канала про тактическую, милитари и airsoft-экипировку.
Пиши от лица канала: как экспертный наблюдатель, а не как производитель или магазин.
Задача: превратить исходный пост в короткую новостную публикацию для канала.
Стиль:
- коротко, уверенно, по делу;
- без рекламного восторга и без выдуманных характеристик;
- только факты из исходного текста;
- если фактов мало, делай короткий пост, не растягивай;
- текст должен быть структурированным, не простынёй.
Формат текста:
- 3-6 коротких строк или 2-4 компактных абзаца;
- не добавляй ссылки и хэштеги в text.
Важное правило про источник:
- producer_name — это источник поста: производитель, магазин или площадка.
- Не утверждай, что producer_name «выпустил», «сделал», «представил» товар, если из текста не ясно, что это именно производитель этого товара.
- Если источник выглядит как магазин/площадка или в тексте упомянут другой бренд, формулируй нейтрально: «у [producer_name] появился...», «[producer_name] показали/добавили...», «в продаже появился...».
- Если из текста явно видно, что источник сам производит товар, можно писать: «[producer_name] выпустили...».
Категория:
- выбери ровно одну category из списка categories во входных данных;
- если сомневаешься, выбирай ближайшую по назначению товара.
OUTPUT: JSON only.
$prompt$::text),
updated_at = NOW()
WHERE key='ai_writer_prompt';
UPDATE app_settings
SET value_json = to_jsonb($contract$
OUTPUT SCHEMA (return array matching input order):
{"rewrites":[{"id":123,"category":"разгрузка","text":"готовый текст без хэштегов","notes":"короткая заметка для редактора или null"}]}
Rules:
- Input is a JSON object with key "posts" containing accepted posts.
- Each post includes producer_name and producer_tag. Use producer_name carefully: it can be a manufacturer, shop, or publishing source.
- Do not add hashtags to rewritten text. Pick exactly one category from the categories list in input.
- Return JSON only. No markdown. No text outside JSON.
- Return one rewrite for every input post id.
$contract$::text),
updated_at = NOW()
WHERE key='ai_writer_contract';
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS content_categories (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
tag TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_content_categories_active_order
ON content_categories(is_active, sort_order, name);
INSERT INTO content_categories(name, tag, sort_order)
SELECT value, regexp_replace(lower(value), '[^[:alnum:]_а-яё]+', '_', 'g'), ordinality::int
FROM app_settings s
CROSS JOIN LATERAL jsonb_array_elements_text(
CASE WHEN jsonb_typeof(s.value_json) = 'array' THEN s.value_json ELSE '[]'::jsonb END
) WITH ORDINALITY AS items(value, ordinality)
WHERE s.key = 'ai_writer_categories'
ON CONFLICT (name) DO UPDATE
SET tag = EXCLUDED.tag,
sort_order = LEAST(content_categories.sort_order, EXCLUDED.sort_order),
updated_at = NOW();
@@ -0,0 +1,25 @@
UPDATE app_settings
SET value_json = to_jsonb(
trim(
regexp_replace(
value_json #>> '{}',
E'\\n?OUTPUT:[[:space:]]*JSON only\\.[[:space:]]*',
E'\\n',
'gi'
)
)
),
updated_at = NOW()
WHERE key = 'ai_writer_prompt'
AND value_json #>> '{}' ~* 'OUTPUT:[[:space:]]*JSON only\.';
UPDATE app_settings
SET value_json = to_jsonb(
CASE
WHEN value_json #>> '{}' ~* 'Return JSON only\\.'
THEN value_json #>> '{}'
ELSE trim(value_json #>> '{}') || E'\n- Return JSON only. No markdown. No text outside JSON.'
END
),
updated_at = NOW()
WHERE key = 'ai_writer_contract';
@@ -0,0 +1,74 @@
UPDATE app_settings
SET value_json = to_jsonb($prompt$
РОЛЬ
Ты редактор профильного русского Telegram-канала про тактическую, милитари и airsoft-экипировку.
Ты эксперт в тактическом, военном и страйкбольном снаряжении. Последние 10 лет ты следишь за новостями про новинки тактического снаряжения.
Ты профессиональный копирайтер и эксперт по SEO-оптимизации текстов для сайтов и соцсетей.
Пиши от лица канала: как экспертный наблюдатель, а не как производитель или магазин.
ТВОИ ЗНАНИЯ
1. Ты профессионально разбираешься в материалах и технологиях, которые используются в производстве военного и тактического снаряжения: типы тканей, кордуры, стропы, фурнитура, интерфейсы креплений, системы быстрого сброса, мембраны, корсетные системы, баллистическая и противоосколочная защита.
2. Ты разбираешься в практическом применении тактического, военного и страйкбольного снаряжения. Можешь отличить, что подходит для страйкбола, что для гражданских стрелков, что для военных, а что универсально для всех направлений.
3. Ты профессионально разбираешься в истории тактического и военного снаряжения. По конструкции можешь отличить классические и популярные модели от новых разработок последних лет.
ЗАДАЧА
Превратить исходный пост в короткую новостную публикацию для канала.
РЕЗУЛЬТАТ
Создать грамотный, понятный, структурированный и оптимизированный для русскоязычной аудитории текст для публикации в Telegram и ВКонтакте.
Текст должен быть адаптирован для военных, гражданских стрелков и людей, которые интересуются военным, тактическим и страйкбольным снаряжением.
СТРУКТУРА ТЕКСТА
1. SEO-заголовок с обязательным названием компании/источника и названием продукта или предмета новости. Пример: "Новый подсумок XXX от компании YYY".
2. Черта-разделитель.
3. Краткое объяснение, чем интересна новость или новинка. Указывай конкретику, избегай общих слов. Пример: "Арс Арма выпустила подсумок" лучше, чем "Российский производитель снаряжения выпустил подсумок".
4. Тело новости: основные свойства, назначение, технические особенности. Лаконично, используя факты исходного текста, опиши все важные свойства или тезисы. Если блок получился слишком объёмным, сократи его без потери смысла.
5. Если есть в исходнике, кратко укажи материалы и цвета, в которых производится новинка.
6. Если есть в исходнике, укажи стоимость новинки.
7. Черта-разделитель.
8. Фраза: "Подписывайтесь на нас в МАКС, ТГ, ВК".
ФОРМАТ ТЕКСТА
- 3-6 коротких строк или 2-5 компактных абзацев;
- не добавляй ссылки и хэштеги в text.
СТИЛЬ
- коротко, уверенно, по делу;
- без рекламного восторга и без выдуманных характеристик;
- нельзя использовать абстрактные слова типа "успех", "секрет", "мотивация", "справишься" и подобные;
- только факты из исходного текста;
- нельзя придумывать факты;
- если фактов мало, делай короткий пост, не растягивай;
- текст должен быть структурированным, не простынёй;
- используй подход "Пиши, сокращай": конкретика лучше общих и абстрактных формулировок.
ВАЖНОЕ ПРАВИЛО ПРО ИСТОЧНИК
- producer_name — это источник поста: производитель, магазин или площадка.
- Не утверждай, что producer_name "выпустил", "сделал", "представил" товар, если из текста не ясно, что это именно производитель этого товара.
- Если источник выглядит как магазин/площадка или в тексте упомянут другой бренд, формулируй нейтрально: "у [producer_name] появился...", "[producer_name] показали/добавили...", "в продаже появился...".
- Если из текста явно видно, что источник сам производит товар, можно писать: "[producer_name] выпустили...".
КАТЕГОРИЯ
- выбери ровно одну category из списка categories во входных данных;
- если сомневаешься, выбирай ближайшую по назначению товара.
$prompt$::text),
updated_at = NOW()
WHERE key='ai_writer_prompt';
UPDATE app_settings
SET value_json = to_jsonb($contract$
OUTPUT SCHEMA (return array matching input order):
{"rewrites":[{"id":123,"category":"разгрузка","text":"готовый текст без хэштегов","notes":"короткая заметка для редактора или null"}]}
Rules:
- Input is a JSON object with key "posts" containing accepted posts.
- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.
- Pick exactly one category from the categories list in input.
- Do not add links or hashtags to rewritten text.
- Return JSON only. No markdown. No text outside JSON.
- Return one rewrite for every input post id.
- The "text" field must be a ready-to-publish Russian post with normal paragraph line breaks encoded as JSON string newlines.
- The "notes" field must be a short editor note in Russian or null.
$contract$::text),
updated_at = NOW()
WHERE key='ai_writer_contract';
+74
View File
@@ -0,0 +1,74 @@
ALTER TABLE raw_posts
ADD COLUMN IF NOT EXISTS rewrite_category_id INTEGER,
ADD COLUMN IF NOT EXISTS final_category_id INTEGER;
WITH duplicate_categories AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY sort_order ORDER BY is_active DESC, id) AS duplicate_rank
FROM content_categories
),
renumber AS (
SELECT id,
(SELECT COALESCE(MAX(sort_order), 0) FROM content_categories)
+ ROW_NUMBER() OVER (ORDER BY id) AS new_sort_order
FROM duplicate_categories
WHERE duplicate_rank > 1
)
UPDATE content_categories c
SET sort_order = r.new_sort_order,
updated_at = NOW()
FROM renumber r
WHERE c.id = r.id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_content_categories_public_id
ON content_categories(sort_order);
UPDATE raw_posts rp
SET rewrite_category_id = c.sort_order
FROM content_categories c
WHERE rp.rewrite_category_id IS NULL
AND rp.rewrite_category IS NOT NULL
AND (
lower(c.name) = lower(rp.rewrite_category)
OR lower(c.tag) = lower(COALESCE(rp.rewrite_category_tag, ''))
);
UPDATE raw_posts rp
SET final_category_id = c.sort_order
FROM content_categories c
WHERE rp.final_category_id IS NULL
AND rp.final_category IS NOT NULL
AND (
lower(c.name) = lower(rp.final_category)
OR lower(c.tag) = lower(COALESCE(rp.final_category_tag, ''))
);
UPDATE app_settings
SET value_json = to_jsonb(
replace(
value_json #>> '{}',
E'- выбери ровно одну category из списка categories во входных данных;\n- если сомневаешься, выбирай ближайшую по назначению товара.',
E'- выбери ровно один category_id из списка categories во входных данных;\n- categories приходит списком объектов: id, name, tag;\n- если сомневаешься, выбирай ближайший id по назначению товара.'
)
),
updated_at = NOW()
WHERE key='ai_writer_prompt';
UPDATE app_settings
SET value_json = to_jsonb($contract$
OUTPUT SCHEMA (return array matching input order):
{"rewrites":[{"id":123,"category_id":2,"text":"готовый текст без хэштегов","notes":"короткая заметка для редактора или null"}]}
Rules:
- Input is a JSON object with key "posts" containing accepted posts.
- Input has key "categories" with objects: id, name, tag.
- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.
- Pick exactly one category from the categories list in input and return its numeric id as category_id.
- Do not add links or hashtags to rewritten text.
- Return JSON only. No markdown. No text outside JSON.
- Return one rewrite for every input post id.
- The "text" field must be a ready-to-publish Russian post with normal paragraph line breaks encoded as JSON string newlines.
- The "notes" field must be a short editor note in Russian or null.
$contract$::text),
updated_at = NOW()
WHERE key='ai_writer_contract';
@@ -0,0 +1,19 @@
UPDATE app_settings
SET value_json = to_jsonb($contract$
OUTPUT SCHEMA (return array matching input order):
{"rewrites":[{"id":123,"category_id":2,"text":"готовый текст без хэштегов","notes":"короткая заметка для редактора или null"}]}
Rules:
- Input is a JSON object with key "posts" containing accepted posts.
- Input has key "categories" with objects: id, name, tag.
- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.
- Pick exactly one category from the categories list in input and return its numeric id as category_id.
- Do not add links or hashtags to rewritten text.
- Return JSON only. No markdown. No text outside JSON.
- Never return an empty object. Never omit the "rewrites" key.
- Return one rewrite for every input post id.
- The "text" field must be a ready-to-publish Russian post with normal paragraph line breaks encoded as JSON string newlines.
- The "notes" field must be a short editor note in Russian or null.
$contract$::text),
updated_at = NOW()
WHERE key='ai_writer_contract';
@@ -0,0 +1,15 @@
DELETE FROM audit_log
WHERE created_at < NOW() - INTERVAL '30 days';
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at
ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_action_time
ON audit_log(action, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_time
ON audit_log(actor_id, created_at DESC)
WHERE actor_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_entity_time
ON audit_log(entity_type, entity_id, created_at DESC);
@@ -0,0 +1,15 @@
UPDATE app_settings s
SET value_json = to_jsonb(wc.enabled),
updated_at = NOW()
FROM worker_controls wc
WHERE (wc.name, s.key) IN (
('ai-qualifier', 'ai_qualifier_enabled'),
('ai-writer', 'ai_writer_enabled')
);
UPDATE raw_posts
SET rewrite_status='failed',
rewrite_notes=COALESCE(rewrite_notes, 'recovered stale writer processing state'),
updated_at=NOW()
WHERE rewrite_status='processing'
AND updated_at < NOW() - INTERVAL '30 minutes';
@@ -0,0 +1,21 @@
-- Keep the AI-writer divider deterministic.
-- Target separator length: 27 characters.
UPDATE app_settings
SET value_json = to_jsonb(
replace(
value_json #>> '{}',
'2. Черта с точным количеством символов ━━━━━━━━━━━━━━━━━━━━━━━━━━━',
'2. Черта-разделитель: отдельная строка ровно из 27 символов. Используй строго эту строку без изменений: ━━━━━━━━━━━━━━━━━━━━━━━━━━━'
)
),
updated_at = NOW()
WHERE key = 'ai_writer_prompt'
AND (value_json #>> '{}') LIKE '%Черта с точным количеством символов%';
UPDATE raw_posts
SET rewritten_text = regexp_replace(rewritten_text, E'(^━{3,}$)', '━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'gm'),
final_text = regexp_replace(final_text, E'(^━{3,}$)', '━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'gm'),
updated_at = NOW()
WHERE COALESCE(rewritten_text, '') LIKE '%━%'
OR COALESCE(final_text, '') LIKE '%━%';
+93
View File
@@ -0,0 +1,93 @@
ALTER TABLE raw_posts
ADD COLUMN IF NOT EXISTS publication_status TEXT NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS publication_error TEXT,
ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS tg_publication_chat_id BIGINT,
ADD COLUMN IF NOT EXISTS tg_publication_thread_id BIGINT,
ADD COLUMN IF NOT EXISTS tg_publication_message_ids BIGINT[],
ADD COLUMN IF NOT EXISTS tg_publication_url TEXT;
CREATE INDEX IF NOT EXISTS idx_raw_posts_publication_queue
ON raw_posts(publication_status, editorial_status, rewrite_status, published_at, reviewed_at, id);
CREATE INDEX IF NOT EXISTS idx_raw_posts_publication_rank
ON raw_posts(final_category_tag, final_source_tag, published_at DESC);
CREATE TABLE IF NOT EXISTS publication_runs (
id BIGSERIAL PRIMARY KEY,
poster TEXT NOT NULL,
schedule_id TEXT NOT NULL,
scheduled_for DATE NOT NULL,
scheduled_time TEXT NOT NULL,
planned_count INTEGER NOT NULL DEFAULT 0,
published_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'started',
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(poster, schedule_id, scheduled_for)
);
CREATE INDEX IF NOT EXISTS idx_publication_runs_poster_date
ON publication_runs(poster, scheduled_for DESC, scheduled_time);
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('tg-poster', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('tg_poster_enabled', 'false'::jsonb, 'bool', 'TG poster enabled', 'Double safety flag for publishing accepted posts to Telegram.', 'TG Poster'),
('tg_poster_bot_token', '""'::jsonb, 'secret', 'TG poster bot token', 'Bot token for final Telegram publication. For test can be the same as upload bot token.', 'TG Poster'),
('tg_poster_chat_id', '""'::jsonb, 'str', 'TG publication chat id', 'Target Telegram channel/group id. Supports chat_id:thread_id for topics.', 'TG Poster'),
('tg_poster_schedule_json', '[{"id":"morning","time":"10:00","count":1,"enabled":true},{"id":"evening","time":"18:00","count":1,"enabled":true}]'::jsonb, 'json', 'TG publication schedule', 'EKB time schedule rows: id, time HH:MM, count, enabled.', 'TG Poster'),
('tg_poster_interval_sec', '60'::jsonb, 'int', 'TG poster interval, sec', 'Pause between poster checks.', 'TG Poster'),
('tg_poster_media_group_max_items', '10'::jsonb, 'int', 'TG media group max items', 'Telegram media group supports 2..10 media items.', 'TG Poster'),
('tg_poster_caption_limit', '1024'::jsonb, 'int', 'TG caption limit', 'Telegram caption limit for media messages.', 'TG Poster'),
('tg_poster_message_limit', '4096'::jsonb, 'int', 'TG message limit', 'Telegram text message limit.', 'TG Poster'),
('tg_poster_max_attempts', '3'::jsonb, 'int', 'TG post retry attempts', 'Retries for Telegram API errors.', 'TG Poster'),
('tg_poster_retry_backoff_max_sec', '30'::jsonb, 'int', 'TG retry backoff max, sec', 'Max retry sleep for Telegram API errors.', 'TG Poster'),
('tg_poster_send_delay_sec', '1'::jsonb, 'float', 'TG send delay, sec', 'Delay between Telegram sends.', 'TG Poster'),
('tg_poster_text_overflow_caption', '"⬇️ Описание под медиа"'::jsonb, 'str', 'TG overflow caption', 'Caption used when full text is longer than Telegram media caption limit.', 'TG Poster'),
('tg_poster_recent_window', '20'::jsonb, 'int', 'Ranking recent window', 'How many published posts are checked to reduce repeated categories and sources.', 'TG Poster'),
('tg_poster_category_repeat_penalty', '3'::jsonb, 'float', 'Category repeat penalty', 'Ranking penalty for recently published same category.', 'TG Poster'),
('tg_poster_source_repeat_penalty', '4'::jsonb, 'float', 'Source repeat penalty', 'Ranking penalty for recently published same source.', 'TG Poster')
ON CONFLICT (key) DO NOTHING;
UPDATE app_settings
SET description='Обрезает исходный raw-текст перед отправкой в LLM. Итоговый rewrite должен быть компактным: Telegram caption 1024 символа минус будущая строка хэштегов.'
WHERE key='ai_writer_max_text_chars';
UPDATE app_settings
SET value_json = to_jsonb($contract$
OUTPUT SCHEMA (return array matching input order):
{"rewrites":[{"id":123,"category_id":2,"text":"готовый текст без хэштегов","notes":"короткая заметка для редактора или null"}]}
Rules:
- Input is a JSON object with key "posts" containing accepted posts.
- Input has key "categories" with objects: id, name, tag.
- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.
- Pick exactly one category from the categories list in input and return its numeric id as category_id.
- Do not add links or hashtags to rewritten text.
- Keep text compact enough for Telegram media caption: target 850 characters, hard maximum 900 characters.
- Remember the system appends category and source hashtags after text, so leave space for them.
- Return JSON only. No markdown. No text outside JSON.
- Never return an empty object. Never omit the "rewrites" key.
- Return one rewrite for every input post id.
- The "text" field must be a ready-to-publish Russian post with normal paragraph line breaks encoded as JSON string newlines.
- The "notes" field must be a short editor note in Russian or null.
$contract$::text),
updated_at = NOW()
WHERE key='ai_writer_contract';
UPDATE app_settings
SET value_json = to_jsonb(
regexp_replace(
value_json #>> '{}',
'- 3-6 коротких строк или 2-5 компактных абзацев;',
'- 3-6 коротких строк или 2-4 компактных абзаца; итоговый text должен целиться в 850 символов и не превышать 900 символов, потому что система добавит хэштеги и Telegram caption ограничен 1024 символами;',
'g'
)
)
WHERE key='ai_writer_prompt'
AND (value_json #>> '{}') LIKE '%3-6 коротких строк или 2-5 компактных абзацев%';
@@ -0,0 +1,46 @@
UPDATE app_settings
SET value_json = '"⬇️ Описание"'::jsonb,
updated_at = NOW()
WHERE key='tg_poster_text_overflow_caption';
UPDATE app_settings
SET value_json = $schedule$[
{"id":"hour-00","time":"00:00","count":4,"enabled":true},
{"id":"hour-01","time":"01:00","count":4,"enabled":true},
{"id":"hour-02","time":"02:00","count":4,"enabled":true},
{"id":"hour-03","time":"03:00","count":4,"enabled":true},
{"id":"hour-04","time":"04:00","count":4,"enabled":true},
{"id":"hour-05","time":"05:00","count":4,"enabled":true},
{"id":"hour-06","time":"06:00","count":4,"enabled":true},
{"id":"hour-07","time":"07:00","count":4,"enabled":true},
{"id":"hour-08","time":"08:00","count":4,"enabled":true},
{"id":"hour-09","time":"09:00","count":4,"enabled":true},
{"id":"hour-10","time":"10:00","count":4,"enabled":true},
{"id":"hour-11","time":"11:00","count":4,"enabled":true},
{"id":"hour-12","time":"12:00","count":4,"enabled":true},
{"id":"hour-13","time":"13:00","count":4,"enabled":true},
{"id":"hour-14","time":"14:00","count":4,"enabled":true},
{"id":"hour-15","time":"15:00","count":4,"enabled":true},
{"id":"hour-16","time":"16:00","count":4,"enabled":true},
{"id":"hour-17","time":"17:00","count":4,"enabled":true},
{"id":"hour-18","time":"18:00","count":4,"enabled":true},
{"id":"hour-19","time":"19:00","count":4,"enabled":true},
{"id":"hour-20","time":"20:00","count":4,"enabled":true},
{"id":"hour-21","time":"21:00","count":4,"enabled":true},
{"id":"hour-22","time":"22:00","count":4,"enabled":true},
{"id":"hour-23","time":"23:00","count":4,"enabled":true}
]$schedule$::jsonb,
updated_at = NOW()
WHERE key='tg_poster_schedule_json';
UPDATE raw_posts
SET editorial_status='published',
updated_at=NOW()
WHERE publication_status='published'
AND editorial_status='accepted';
UPDATE raw_posts
SET editorial_status='publish_failed',
updated_at=NOW()
WHERE publication_status='publish_failed'
AND editorial_status='accepted';
@@ -0,0 +1,44 @@
-- Keep the AI-writer divider deterministic.
-- Target separator length: 17 characters.
UPDATE app_settings
SET value_json = to_jsonb(
regexp_replace(
value_json #>> '{}',
'Черта-разделитель:[^\n]*',
'Черта-разделитель: отдельная строка ровно из 17 символов. Используй строго эту строку без изменений: ━━━━━━━━━━━━━━━━━',
'g'
)
),
updated_at = NOW()
WHERE key = 'ai_writer_prompt'
AND (value_json #>> '{}') LIKE '%Черта-разделитель%';
UPDATE app_settings
SET value_json = to_jsonb(
CASE
WHEN (value_json #>> '{}') LIKE '%Separator line must be exactly%'
THEN regexp_replace(
value_json #>> '{}',
'Separator line must be exactly[^\n]*',
'Separator line must be exactly this 17-character line when used: ━━━━━━━━━━━━━━━━━',
'g'
)
ELSE (value_json #>> '{}') || E'\n- Separator line must be exactly this 17-character line when used: ━━━━━━━━━━━━━━━━━'
END
),
updated_at = NOW()
WHERE key = 'ai_writer_contract';
UPDATE raw_posts
SET rewritten_text = CASE
WHEN rewritten_text IS NULL THEN NULL
ELSE regexp_replace(rewritten_text, E'^[━─—-]{3,}\\s*$', '━━━━━━━━━━━━━━━━━', 'gm')
END,
final_text = CASE
WHEN final_text IS NULL THEN NULL
ELSE regexp_replace(final_text, E'^[━─—-]{3,}\\s*$', '━━━━━━━━━━━━━━━━━', 'gm')
END,
updated_at = NOW()
WHERE COALESCE(rewritten_text, '') ~ E'(?m)^[━─—-]{3,}\\s*$'
OR COALESCE(final_text, '') ~ E'(?m)^[━─—-]{3,}\\s*$';
@@ -0,0 +1,34 @@
-- Manual Raw-to-rewrite flow is implemented in admin.py.
-- Non-target category (sort_order/category_id 18, tag НЦК) must not enter editorial review.
UPDATE app_settings
SET value_json = to_jsonb(
CASE
WHEN (value_json #>> '{}') LIKE '%category_id 18%'
THEN value_json #>> '{}'
ELSE (value_json #>> '{}') || E'\n- If the post is non-target content, choose category_id 18 ("Не целевой контент", tag "НЦК"); the app will reject it automatically.'
END
),
updated_at = NOW()
WHERE key = 'ai_writer_contract';
UPDATE raw_posts
SET editorial_status='rejected',
qualification_status='rejected',
qualification_decision='rejected',
qualification_model_decision='rejected',
qualification_reason=COALESCE(NULLIF(qualification_reason, ''), 'AI writer selected non-target category'),
qualification_reject_tag=COALESCE(NULLIF(qualification_reject_tag, ''), 'non_target_content'),
editor_notes=COALESCE(NULLIF(editor_notes, ''), 'Отклонено AI: Не целевой контент'),
reviewed_at=COALESCE(reviewed_at, NOW()),
updated_at=NOW()
WHERE rewrite_status='ready'
AND COALESCE(editorial_status, 'review')='review'
AND (
rewrite_category_id=18
OR final_category_id=18
OR lower(COALESCE(rewrite_category_tag, '')) IN ('нцк', 'nck')
OR lower(COALESCE(final_category_tag, '')) IN ('нцк', 'nck')
OR lower(COALESCE(rewrite_category, '')) = lower('Не целевой контент')
OR lower(COALESCE(final_category, '')) = lower('Не целевой контент')
);
@@ -0,0 +1,67 @@
CREATE TABLE IF NOT EXISTS post_publications (
id BIGSERIAL PRIMARY KEY,
raw_post_id BIGINT NOT NULL REFERENCES raw_posts(id) ON DELETE CASCADE,
platform TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
target_id TEXT,
external_id TEXT,
url TEXT,
error TEXT,
attachments_json JSONB NOT NULL DEFAULT '[]'::jsonb,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(raw_post_id, platform)
);
CREATE INDEX IF NOT EXISTS idx_post_publications_platform_status
ON post_publications(platform, status, target_id, published_at DESC);
CREATE INDEX IF NOT EXISTS idx_post_publications_raw_post
ON post_publications(raw_post_id);
INSERT INTO post_publications(raw_post_id, platform, status, target_id, external_id, url, error, attachments_json, published_at, updated_at)
SELECT
id,
'tg',
publication_status,
tg_publication_chat_id::text,
CASE WHEN tg_publication_message_ids IS NULL THEN NULL ELSE array_to_string(tg_publication_message_ids, ',') END,
tg_publication_url,
publication_error,
COALESCE(to_jsonb(tg_publication_message_ids), '[]'::jsonb),
published_at,
NOW()
FROM raw_posts
WHERE COALESCE(publication_status, 'pending') <> 'pending'
ON CONFLICT (raw_post_id, platform) DO UPDATE
SET status=EXCLUDED.status,
target_id=EXCLUDED.target_id,
external_id=EXCLUDED.external_id,
url=EXCLUDED.url,
error=EXCLUDED.error,
attachments_json=EXCLUDED.attachments_json,
published_at=EXCLUDED.published_at,
updated_at=NOW();
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('vk-poster', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('vk_poster_enabled', 'false'::jsonb, 'bool', 'VK poster enabled', 'Double safety flag for publishing accepted posts to VK.', 'VK Poster'),
('vk_poster_access_token', '""'::jsonb, 'secret', 'VK poster access token', 'Group/user token with wall.post and photos permissions. If empty, env VK_GROUP_ACCESS_TOKEN/VK_ACCESS_TOKEN is used.', 'VK Poster'),
('vk_poster_owner_id', '0'::jsonb, 'int', 'VK target owner_id', 'Target VK group owner_id, usually negative: -123456789.', 'VK Poster'),
('vk_poster_from_group', 'true'::jsonb, 'bool', 'Post from group', 'Use from_group=1 when publishing to a community wall.', 'VK Poster'),
('vk_poster_schedule_json', '[{"id":"vk-morning","time":"10:05","count":1,"enabled":true},{"id":"vk-evening","time":"18:05","count":1,"enabled":true}]'::jsonb, 'json', 'VK publication schedule', 'EKB time schedule rows: id, time HH:MM, count, enabled.', 'VK Poster'),
('vk_poster_interval_sec', '60'::jsonb, 'int', 'VK poster interval, sec', 'Pause between poster checks.', 'VK Poster'),
('vk_poster_timeout_sec', '90'::jsonb, 'int', 'VK API timeout, sec', 'Total timeout for VK API calls and media uploads.', 'VK Poster'),
('vk_poster_max_attempts', '3'::jsonb, 'int', 'VK post retry attempts', 'Retries for VK API/network errors.', 'VK Poster'),
('vk_poster_retry_backoff_max_sec', '30'::jsonb, 'int', 'VK retry backoff max, sec', 'Max retry sleep for VK API/network errors.', 'VK Poster'),
('vk_poster_send_delay_sec', '1'::jsonb, 'float', 'VK send delay, sec', 'Delay between VK posts.', 'VK Poster'),
('vk_poster_recent_window', '20'::jsonb, 'int', 'Ranking recent window', 'How many VK-published posts are checked to reduce repeated categories and sources.', 'VK Poster'),
('vk_poster_category_repeat_penalty', '3'::jsonb, 'float', 'Category repeat penalty', 'Ranking penalty for recently published same category.', 'VK Poster'),
('vk_poster_source_repeat_penalty', '4'::jsonb, 'float', 'Source repeat penalty', 'Ranking penalty for recently published same source.', 'VK Poster'),
('vk_poster_dry_run', 'false'::jsonb, 'bool', 'VK dry run', 'If true, prepares attachments but does not call wall.post. Keep false for production.', 'VK Poster')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,5 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('vk_poster_app_id', '""'::jsonb, 'str', 'VK OAuth app id', 'VK application id used for user OAuth code flow.', 'VK Poster'),
('vk_poster_client_secret', '""'::jsonb, 'secret', 'VK OAuth client secret', 'Protected key from VK application settings. Used server-side to exchange code for user access token.', 'VK Poster')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,6 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('vk_poster_refresh_token', '""'::jsonb, 'secret', 'VK OAuth refresh token', 'Refresh token from VK ID code flow. Used by VK poster to renew access token automatically.', 'VK Poster'),
('vk_poster_token_device_id', '""'::jsonb, 'str', 'VK OAuth device id', 'VK ID device_id returned with authorization code/token exchange.', 'VK Poster'),
('vk_poster_token_expires_at', '""'::jsonb, 'str', 'VK access token expires at', 'UTC ISO timestamp used by VK poster to refresh the access token before expiry.', 'VK Poster')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,18 @@
-- Make producer mention mandatory in every AI writer result.
UPDATE app_settings
SET value_json = to_jsonb(
CASE
WHEN (value_json #>> '{}') LIKE '%explicitly mention producer_name%'
OR (value_json #>> '{}') LIKE '%явно упоминает producer_name%'
THEN value_json #>> '{}'
ELSE replace(
value_json #>> '{}',
'- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.',
'- Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source.' || E'\n' ||
'- The "text" field for every rewrite must explicitly mention producer_name at least once. Do not satisfy this only through producer_tag, hashtags, notes, or metadata.'
)
END
),
updated_at = NOW()
WHERE key = 'ai_writer_contract';
+39
View File
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS post_reactions (
id BIGSERIAL PRIMARY KEY,
publication_id BIGINT NOT NULL REFERENCES post_publications(id) ON DELETE CASCADE,
raw_post_id BIGINT NOT NULL REFERENCES raw_posts(id) ON DELETE CASCADE,
platform TEXT NOT NULL,
reactor_key TEXT NOT NULL,
reaction TEXT NOT NULL,
target_id TEXT NOT NULL,
message_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
reacted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(publication_id, reactor_key, message_id)
);
CREATE INDEX IF NOT EXISTS idx_post_reactions_status
ON post_reactions(platform, status, updated_at);
CREATE INDEX IF NOT EXISTS idx_post_reactions_raw_post
ON post_reactions(raw_post_id);
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('tg-reactor', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('tg_reactor_enabled', 'false'::jsonb, 'bool', 'TG reactor enabled', 'Double safety flag for Telegram reaction worker.', 'TG Reactor'),
('tg_reactor_bot_tokens', '""'::jsonb, 'secret', 'TG reactor bot tokens', 'One or more Telegram bot tokens separated by comma/newline. If empty, uses tg_poster_bot_token/TG_BOT_TOKEN.', 'TG Reactor'),
('tg_reactor_reactions_json', '["👍"]'::jsonb, 'json', 'TG reactions', 'Emoji reactions. One bot can set one reaction; multiple tokens can set multiple reactions.', 'TG Reactor'),
('tg_reactor_delay_sec', '60'::jsonb, 'int', 'Delay after publication, sec', 'How long to wait after TG publication before reacting.', 'TG Reactor'),
('tg_reactor_interval_sec', '60'::jsonb, 'int', 'TG reactor interval, sec', 'Pause between reaction worker checks.', 'TG Reactor'),
('tg_reactor_react_all_messages', 'false'::jsonb, 'bool', 'React to all media messages', 'If false, reacts only to the first message of each published post.', 'TG Reactor'),
('tg_reactor_max_attempts', '3'::jsonb, 'int', 'TG reaction retry attempts', 'Retries per publication/message/reactor.', 'TG Reactor'),
('tg_reactor_retry_backoff_max_sec', '30'::jsonb, 'int', 'TG reaction retry backoff max, sec', 'Max sleep for Telegram reaction retry errors.', 'TG Reactor')
ON CONFLICT (key) DO NOTHING;
+4
View File
@@ -0,0 +1,4 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('tg_reactor_since', '""'::jsonb, 'str', 'React only since', 'UTC ISO timestamp. If set, TG reactor ignores publications older than this moment.', 'TG Reactor')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,4 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('tg_reactor_reaction_pause_sec', '2'::jsonb, 'int', 'Pause between TG reactions, sec', 'Small pause after each successful reaction to avoid a burst of Telegram API calls.', 'TG Reactor')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,14 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('video_max_height', '720'::jsonb, 'int', 'Video max height', 'Maximum VK video height selected by yt-dlp before uploading to Telegram.', 'Uploader')
ON CONFLICT (key) DO NOTHING;
UPDATE app_settings
SET value_json='600'::jsonb,
updated_at=NOW()
WHERE key='uploader_yt_dlp_timeout_sec'
AND CASE
WHEN jsonb_typeof(value_json) = 'number' THEN (value_json #>> '{}')::int
WHEN jsonb_typeof(value_json) = 'string' AND (value_json #>> '{}') ~ '^\d+$' THEN (value_json #>> '{}')::int
ELSE 0
END < 600;
+13
View File
@@ -0,0 +1,13 @@
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('daily-report', TRUE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('daily_report_enabled', 'true'::jsonb, 'bool', 'Daily report enabled', 'Double safety flag for daily Telegram operational report.', 'Daily Report'),
('daily_report_bot_token', '""'::jsonb, 'secret', 'Daily report bot token', 'Telegram bot token for reports. If empty, uses tg_poster_bot_token or TG_BOT_TOKEN.', 'Daily Report'),
('daily_report_recipient_ids', '[442509142]'::jsonb, 'json', 'Daily report recipients', 'Telegram user/chat IDs that receive the daily report.', 'Daily Report'),
('daily_report_time', '"00:04"'::jsonb, 'str', 'Daily report time', 'EKB time HH:MM when yesterday report is sent.', 'Daily Report'),
('daily_report_interval_sec', '60'::jsonb, 'int', 'Daily report interval, sec', 'Pause between checks.', 'Daily Report'),
('daily_report_last_sent_date', '""'::jsonb, 'str', 'Last sent report date', 'Internal YYYY-MM-DD report date already sent automatically.', 'Daily Report')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,57 @@
ALTER TABLE content_categories
ADD COLUMN IF NOT EXISTS site_name TEXT,
ADD COLUMN IF NOT EXISTS site_slug TEXT,
ADD COLUMN IF NOT EXISTS site_enabled BOOLEAN NOT NULL DEFAULT TRUE;
UPDATE content_categories
SET site_name = CASE sort_order
WHEN 1 THEN 'Одежда'
WHEN 2 THEN 'Снаряжение'
WHEN 3 THEN 'Броня'
WHEN 4 THEN 'Аксессуары'
WHEN 5 THEN 'Связь'
WHEN 6 THEN 'Патчи'
WHEN 7 THEN 'Навигация'
WHEN 8 THEN 'Прицелы'
WHEN 9 THEN 'Ночные приборы'
WHEN 10 THEN 'Медицина'
WHEN 11 THEN 'Свет'
WHEN 12 THEN 'Электроника'
WHEN 13 THEN 'Ножи'
WHEN 14 THEN 'Оружейный обвес'
WHEN 15 THEN 'Практика'
WHEN 16 THEN 'Теория'
WHEN 17 THEN 'Пушки'
WHEN 18 THEN 'НЦК'
ELSE initcap(replace(tag, '_', ' '))
END,
site_slug = CASE sort_order
WHEN 1 THEN 'odezhda'
WHEN 2 THEN 'snaryazhenie'
WHEN 3 THEN 'bronya'
WHEN 4 THEN 'aksessuary'
WHEN 5 THEN 'svyaz'
WHEN 6 THEN 'patchi'
WHEN 7 THEN 'navigatsiya'
WHEN 8 THEN 'pricely'
WHEN 9 THEN 'nochnye-pribory'
WHEN 10 THEN 'medicina'
WHEN 11 THEN 'svet'
WHEN 12 THEN 'elektronika'
WHEN 13 THEN 'nozhi'
WHEN 14 THEN 'oruzheynyy-obves'
WHEN 15 THEN 'praktika'
WHEN 16 THEN 'teoriya'
WHEN 17 THEN 'pushki'
WHEN 18 THEN 'nck'
ELSE site_slug
END,
site_enabled = sort_order <> 18,
updated_at = NOW();
ALTER TABLE content_categories
ALTER COLUMN site_name SET NOT NULL,
ALTER COLUMN site_slug SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_content_categories_site_slug
ON content_categories(site_slug);
+15
View File
@@ -0,0 +1,15 @@
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('site-poster', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('site_poster_enabled', 'false'::jsonb, 'bool', 'Site poster enabled', 'Double safety flag for automatic site publishing.', 'Site Poster'),
('site_poster_provider', '""'::jsonb, 'str', 'Site provider', 'Publish adapter: ghost now; wordpress can be added as a code adapter later.', 'Site Poster'),
('site_poster_base_url', '""'::jsonb, 'str', 'Site API base URL', 'Public/admin base URL for the selected site provider.', 'Site Poster'),
('site_poster_secret_file', '""'::jsonb, 'str', 'Site secret file', 'Server path to the selected provider API secret. Keep empty in git.', 'Site Poster'),
('site_poster_fallback_title', '"Новость"'::jsonb, 'str', 'Fallback article title', 'Title used when post text has no first line.', 'Site Poster'),
('site_poster_interval_sec', '300'::jsonb, 'int', 'Site poster interval, sec', 'Pause between automatic queue checks.', 'Site Poster'),
('site_poster_photo_batch_size', '10'::jsonb, 'int', 'Photo batch size', 'Maximum photo-ready posts per automatic run.', 'Site Poster'),
('site_poster_video_batch_size', '1'::jsonb, 'int', 'Video batch size', 'Maximum video-only posts per automatic run.', 'Site Poster')
ON CONFLICT (key) DO NOTHING;
+28
View File
@@ -0,0 +1,28 @@
INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('max-poster', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('max_poster_enabled', 'false'::jsonb, 'bool', 'MAX poster enabled', 'Double safety flag for publishing accepted posts to MAX.', 'MAX Poster'),
('max_poster_bot_token', '""'::jsonb, 'secret', 'MAX poster bot token', 'Bot access token for MAX API. Keep empty in git and set on server.', 'MAX Poster'),
('max_poster_chat_id', '""'::jsonb, 'str', 'MAX target chat id', 'Target MAX channel/group chat_id.', 'MAX Poster'),
('max_poster_api_base_url', '"https://platform-api2.max.ru"'::jsonb, 'str', 'MAX API base URL', 'MAX Bot API base URL. Since 2026 use platform-api2.max.ru.', 'MAX Poster'),
('max_poster_schedule_json', '[{"id":"hour-00","time":"00:00","count":4,"enabled":true},{"id":"hour-01","time":"01:00","count":4,"enabled":true},{"id":"hour-02","time":"02:00","count":4,"enabled":true},{"id":"hour-03","time":"03:00","count":4,"enabled":true},{"id":"hour-04","time":"04:00","count":4,"enabled":true},{"id":"hour-05","time":"05:00","count":4,"enabled":true},{"id":"hour-06","time":"06:00","count":4,"enabled":true},{"id":"hour-07","time":"07:00","count":4,"enabled":true},{"id":"hour-08","time":"08:00","count":4,"enabled":true},{"id":"hour-09","time":"09:00","count":4,"enabled":true},{"id":"hour-10","time":"10:00","count":4,"enabled":true},{"id":"hour-11","time":"11:00","count":4,"enabled":true},{"id":"hour-12","time":"12:00","count":4,"enabled":true},{"id":"hour-13","time":"13:00","count":4,"enabled":true},{"id":"hour-14","time":"14:00","count":4,"enabled":true},{"id":"hour-15","time":"15:00","count":4,"enabled":true},{"id":"hour-16","time":"16:00","count":4,"enabled":true},{"id":"hour-17","time":"17:00","count":4,"enabled":true},{"id":"hour-18","time":"18:00","count":4,"enabled":true},{"id":"hour-19","time":"19:00","count":4,"enabled":true},{"id":"hour-20","time":"20:00","count":4,"enabled":true},{"id":"hour-21","time":"21:00","count":4,"enabled":true},{"id":"hour-22","time":"22:00","count":4,"enabled":true},{"id":"hour-23","time":"23:00","count":4,"enabled":true}]'::jsonb, 'json', 'MAX publication schedule', 'EKB time schedule rows: id, time HH:MM, count, enabled. Defaults to the TG hourly schedule.', 'MAX Poster'),
('max_poster_interval_sec', '60'::jsonb, 'int', 'MAX poster interval, sec', 'Pause between poster checks.', 'MAX Poster'),
('max_poster_message_limit', '4000'::jsonb, 'int', 'MAX message limit', 'MAX text message limit.', 'MAX Poster'),
('max_poster_media_max_items', '10'::jsonb, 'int', 'MAX media max items', 'Maximum media attachments per MAX message.', 'MAX Poster'),
('max_poster_media_process_delay_sec', '3'::jsonb, 'float', 'MAX media process delay, sec', 'Pause after media upload before sending the message.', 'MAX Poster'),
('max_poster_video_ready_attempts', '5'::jsonb, 'int', 'MAX video ready attempts', 'Retries for sending a message while MAX is still processing uploaded video.', 'MAX Poster'),
('max_poster_video_ready_delay_sec', '10'::jsonb, 'float', 'MAX video ready delay, sec', 'Initial retry delay while MAX is processing uploaded video. Delay doubles after each attempt.', 'MAX Poster'),
('max_poster_max_attempts', '3'::jsonb, 'int', 'MAX post retry attempts', 'Retries for MAX API/network errors and attachment processing.', 'MAX Poster'),
('max_poster_retry_backoff_max_sec', '30'::jsonb, 'int', 'MAX retry backoff max, sec', 'Max retry sleep for MAX API/network errors.', 'MAX Poster'),
('max_poster_send_delay_sec', '1'::jsonb, 'float', 'MAX send delay, sec', 'Delay between MAX sends.', 'MAX Poster'),
('max_poster_recent_window', '20'::jsonb, 'int', 'Ranking recent window', 'How many MAX-published posts are checked to reduce repeated categories and sources.', 'MAX Poster'),
('max_poster_category_repeat_penalty', '3'::jsonb, 'float', 'Category repeat penalty', 'Ranking penalty for recently published same category.', 'MAX Poster'),
('max_poster_source_repeat_penalty', '4'::jsonb, 'float', 'Source repeat penalty', 'Ranking penalty for recently published same source.', 'MAX Poster'),
('max_poster_auto_reaction_enabled', 'true'::jsonb, 'bool', 'MAX auto reaction enabled', 'Try to set a reaction after publishing. If MAX API rejects it, publication remains successful.', 'MAX Poster'),
('max_poster_auto_reaction', '"👍"'::jsonb, 'str', 'MAX auto reaction', 'Emoji reaction to try after publishing.', 'MAX Poster'),
('max_poster_reaction_path_template', '"/messages/{message_id}/reactions"'::jsonb, 'str', 'MAX reaction path template', 'Relative API path for reaction call if supported by MAX API.', 'MAX Poster'),
('max_poster_dry_run', 'false'::jsonb, 'bool', 'MAX dry run', 'If true, prepares publication but does not call MAX send message.', 'MAX Poster')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,36 @@
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES
('site_poster_provider', '""'::jsonb, 'str', 'Site provider', 'Publish adapter: ghost now; wordpress can be added as a code adapter later.', 'Site Poster'),
('site_poster_base_url', '""'::jsonb, 'str', 'Site API base URL', 'Public/admin base URL for the selected site provider.', 'Site Poster'),
('site_poster_secret_file', '""'::jsonb, 'str', 'Site secret file', 'Server path to the selected provider API secret. Keep empty in git.', 'Site Poster'),
('site_poster_fallback_title', '"Новость"'::jsonb, 'str', 'Fallback article title', 'Title used when post text has no first line.', 'Site Poster')
ON CONFLICT (key) DO NOTHING;
UPDATE app_settings
SET value_json='"ghost"'::jsonb,
updated_at=NOW()
WHERE key='site_poster_provider'
AND COALESCE(value_json #>> '{}', '') = ''
AND EXISTS (
SELECT 1 FROM app_settings legacy
WHERE legacy.key='site_poster_ghost_url'
AND COALESCE(legacy.value_json #>> '{}', '') <> ''
);
UPDATE app_settings target
SET value_json=to_jsonb(legacy.value_json #>> '{}'),
updated_at=NOW()
FROM app_settings legacy
WHERE target.key='site_poster_base_url'
AND legacy.key='site_poster_ghost_url'
AND COALESCE(target.value_json #>> '{}', '') = ''
AND COALESCE(legacy.value_json #>> '{}', '') <> '';
UPDATE app_settings target
SET value_json=to_jsonb(legacy.value_json #>> '{}'),
updated_at=NOW()
FROM app_settings legacy
WHERE target.key='site_poster_secret_file'
AND legacy.key='site_poster_key_file'
AND COALESCE(target.value_json #>> '{}', '') = ''
AND COALESCE(legacy.value_json #>> '{}', '') <> '';