Initial RAA parser poster copy
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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%'
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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 '%━%';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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 #>> '{}', '') <> '';
|
||||
Reference in New Issue
Block a user