Update UI and migrations, fix dockerfile
This commit is contained in:
@@ -21,8 +21,6 @@ SET value_json = to_jsonb($prompt$
|
||||
|
||||
Формат текста:
|
||||
- 3-6 коротких строк или 2-4 компактных абзаца;
|
||||
- можно использовать 1-3 тематических эмоджи как маркеры структуры, например: 🧵 материал/конструкция, 🎒 переноска, 🛡 защита, ⚙️ детали, 📦 комплект/наличие, 🎯 назначение;
|
||||
- эмоджи должны помогать читать текст, а не украшать каждую строку;
|
||||
- не добавляй ссылки и хэштеги в text.
|
||||
|
||||
Важное правило про источник:
|
||||
|
||||
@@ -30,8 +30,6 @@ SET value_json = to_jsonb($prompt$
|
||||
|
||||
ФОРМАТ ТЕКСТА
|
||||
- 3-6 коротких строк или 2-5 компактных абзацев;
|
||||
- можно использовать 1-3 тематических эмоджи как маркеры структуры, например: 🧵 материал/конструкция, 🎒 переноска, 🛡 защита, ⚙️ детали, 📦 комплект/наличие, 🎯 назначение;
|
||||
- эмоджи должны помогать читать текст, а не украшать каждую строку;
|
||||
- не добавляй ссылки и хэштеги в text.
|
||||
|
||||
СТИЛЬ
|
||||
|
||||
@@ -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', '"-1003712724327"'::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,13 @@
|
||||
INSERT INTO worker_controls(name, enabled, settings_json)
|
||||
VALUES ('site-poster', TRUE, '{}'::jsonb)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
|
||||
VALUES
|
||||
('site_poster_enabled', 'true'::jsonb, 'bool', 'Site poster enabled', 'Double safety flag for automatic Ghost publishing.', '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'),
|
||||
('site_poster_ghost_url', '"https://f-n8.ru"'::jsonb, 'str', 'Ghost URL', 'Target Ghost public URL.', 'Site Poster'),
|
||||
('site_poster_key_file', '"/opt/vk-parser/.site-poster-key"'::jsonb, 'str', 'Ghost key file', 'Server path to the Ghost Admin API key.', '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;
|
||||
Reference in New Issue
Block a user