diff --git a/db/migrations/020_writer_category_ids.sql b/db/migrations/020_writer_category_ids.sql new file mode 100644 index 0000000..d8801ee --- /dev/null +++ b/db/migrations/020_writer_category_ids.sql @@ -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'; diff --git a/src/vk_parser_app/admin.py b/src/vk_parser_app/admin.py index 52d1701..771030d 100644 --- a/src/vk_parser_app/admin.py +++ b/src/vk_parser_app/admin.py @@ -92,7 +92,7 @@ PROMPT_HINTS = { ), "safe": "Обычно не трогаем. Если промпт райтера переписывается, свободную часть меняем выше, контракт оставляем стабильным.", "input": "Контракт склеивается после свободной инструкции и отправляется как system prompt.", - "contract": '{"rewrites":[{"id":123,"category":"разгрузка","text":"готовый текст без хэштегов","notes":"короткая заметка для редактора"}]}', + "contract": '{"rewrites":[{"id":123,"category_id":2,"text":"готовый текст без хэштегов","notes":"короткая заметка для редактора"}]}', }, } @@ -898,6 +898,25 @@ async def writer_categories() -> list[str]: ] +async def writer_category_payload() -> list[dict[str, Any]]: + pool = await get_pool() + rows = await pool.fetch( + """ + SELECT sort_order AS id, name, tag + FROM content_categories + WHERE is_active=TRUE + ORDER BY sort_order, name + """ + ) + categories = [{"id": int(row["id"]), "name": str(row["name"]), "tag": str(row["tag"])} for row in rows] + if categories: + return categories + return [ + {"id": idx + 1, "name": name, "tag": normalize_hash_tag(name, "category")} + for idx, name in enumerate(await writer_categories()) + ] + + async def category_rows() -> list[dict[str, Any]]: pool = await get_pool() rows = await pool.fetch( @@ -1040,7 +1059,12 @@ def prompt_test_preview_text(parsed: Any) -> str: def prompt_test_preview_category(parsed: Any) -> str: rewrite = prompt_test_first_rewrite(parsed) - return str((rewrite or {}).get("category") or "").strip() + if not rewrite: + return "" + category_id = rewrite.get("category_id") + if category_id not in {None, ""}: + return f"category_id: {category_id}" + return str(rewrite.get("category") or "").strip() def prompt_test_preview_notes(parsed: Any) -> str: @@ -2337,14 +2361,14 @@ async def prompt_test(request: Request, post_id: int | None = None, q_status: st return redirect("/login") q_status = normalize_prompt_test_status(q_status) posts, selected = await prompt_test_posts(post_id, q_status) - categories = await writer_categories() + category_payload = await writer_category_payload() max_text_chars = max(200, int(await fetch_setting("ai_writer_max_text_chars", 3500) or 3500)) prompt = str(await fetch_setting("ai_writer_prompt", "") or "") provider = str(await fetch_setting("ai_writer_provider", "anthropic") or "anthropic") model = str(await fetch_setting("ai_writer_model", "") or "") payload = prompt_test_payload(selected, max_text_chars) if selected else [] if payload: - payload["categories"] = categories + payload["categories"] = category_payload return templates.TemplateResponse( "prompt_test.html", base_context( @@ -2379,7 +2403,7 @@ async def prompt_test_run( require_csrf(user, csrf_token) q_status = normalize_prompt_test_status(q_status) posts, selected = await prompt_test_posts(post_id, q_status) - categories = await writer_categories() + category_payload = await writer_category_payload() max_text_chars = max(200, int(await fetch_setting("ai_writer_max_text_chars", 3500) or 3500)) provider = str(await fetch_setting("ai_writer_provider", "anthropic") or "anthropic") model = str(await fetch_setting("ai_writer_model", "") or "").strip() @@ -2390,7 +2414,7 @@ async def prompt_test_run( normalized_prompt = prompt.replace("\\r\\n", "\n").replace("\\n", "\n").strip() payload = prompt_test_payload(selected, max_text_chars) if selected else [] if payload: - payload["categories"] = categories + payload["categories"] = category_payload result = None error = None if not selected: @@ -2533,7 +2557,7 @@ async def category_create( return redirect("/workers") tag = normalize_hash_tag(tag or name, "category") pool = await get_pool() - sort_order = int(await pool.fetchval("SELECT COALESCE(MAX(sort_order), 0) + 10 FROM content_categories") or 10) + sort_order = int(await pool.fetchval("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM content_categories") or 1) await pool.execute( """ INSERT INTO content_categories(name, tag, sort_order) @@ -2558,7 +2582,6 @@ async def category_update( csrf_token: str = Form(...), name: str = Form(...), tag: str = Form(""), - sort_order: int = Form(0), ): user = await get_current_user(request) if not user: @@ -2574,14 +2597,12 @@ async def category_update( UPDATE content_categories SET name=$2, tag=$3, - sort_order=$4, updated_at=NOW() WHERE id=$1 """, category_id, name, tag, - sort_order, ) await audit(user["id"], "category.update", "content_category", category_id, {"name": name, "tag": tag}) return redirect("/workers") diff --git a/src/vk_parser_app/templates/workers.html b/src/vk_parser_app/templates/workers.html index 3834511..28a54cd 100644 --- a/src/vk_parser_app/templates/workers.html +++ b/src/vk_parser_app/templates/workers.html @@ -19,7 +19,7 @@
Категории публикаций -

Активные категории попадают в выпадающий список редактора и передаются AI-райтеру. Удаление здесь выключает категорию, но не ломает старые посты.

+

Активные категории попадают в редакторскую и передаются AI-райтеру как ID, название и тэг. ID стабилен: модель возвращает его в JSON, а тэг подставляется из БД.

@@ -27,7 +27,7 @@
- + {% for c in categories %} - +
НазваниеТэгПорядокСтатус
НазваниеТэгIDСтатус
@@ -37,7 +37,7 @@ {% if c.is_active %}активна{% else %}выключена{% endif %} diff --git a/src/vk_parser_app/workers/ai_writer.py b/src/vk_parser_app/workers/ai_writer.py index 430e192..7614b2b 100644 --- a/src/vk_parser_app/workers/ai_writer.py +++ b/src/vk_parser_app/workers/ai_writer.py @@ -32,12 +32,14 @@ def normalize_prompt(prompt: str) -> str: DEFAULT_CONTRACT_PROMPT = """ OUTPUT SCHEMA (return array matching input order): -{"rewrites":[{"id":123,"category":"разгрузка","text":"готовый текст без хэштегов","notes":"короткая заметка для редактора или null"}]} +{"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. -- Do not add hashtags to rewritten text. Pick exactly one category from the categories list in input. +- 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. @@ -108,7 +110,7 @@ def response_usage(response: Any) -> dict[str, Any]: def validate_rewrites( data: dict, expected_ids: set[int], - categories: list[str], + categories: list[dict[str, Any]], source_tags_by_id: dict[int, str], ) -> list[dict]: rewrites = data.get("rewrites") @@ -127,18 +129,26 @@ def validate_rewrites( text = str(item.get("text") or "").strip() if len(text) < 40: raise ValueError(f"AI returned too short rewrite for id={post_id}") - category = str(item.get("category") or "").strip().strip("#") - category_map = {c.lower(): c for c in categories} - if category.lower() not in category_map: - raise ValueError(f"AI returned unknown category={category!r} for id={post_id}") - category = category_map[category.lower()] + by_id = {int(c["id"]): c for c in categories if c.get("id") is not None} + by_name = {str(c["name"]).lower(): c for c in categories if c.get("name")} + if item.get("category_id") is not None: + category_id = int(item["category_id"]) + if category_id not in by_id: + raise ValueError(f"AI returned unknown category_id={category_id!r} for id={post_id}") + category_row = by_id[category_id] + else: + category_name = str(item.get("category") or "").strip().strip("#") + category_row = by_name.get(category_name.lower()) + if not category_row: + raise ValueError(f"AI returned unknown category={category_name!r} for id={post_id}") notes = str(item.get("notes") or "").strip()[:1000] seen.add(post_id) out.append( { "id": post_id, - "category": category, - "category_tag": normalize_hash_tag(category, "category"), + "category_id": int(category_row["id"]), + "category": str(category_row["name"]), + "category_tag": normalize_hash_tag(category_row.get("tag") or category_row.get("name") or "", "category"), "source_tag": normalize_hash_tag(source_tags_by_id.get(post_id) or "", "source"), "text": text, "notes": notes, @@ -150,11 +160,11 @@ def validate_rewrites( return out -async def load_writer_categories(pool) -> list[str]: +async def load_writer_categories(pool) -> list[dict[str, Any]]: try: rows = await pool.fetch( """ - SELECT name + SELECT sort_order AS id, name, tag FROM content_categories WHERE is_active=TRUE ORDER BY sort_order, name @@ -162,11 +172,11 @@ async def load_writer_categories(pool) -> list[str]: ) except Exception: rows = [] - categories = [str(row["name"]) for row in rows] + categories = [{"id": int(row["id"]), "name": str(row["name"]), "tag": str(row["tag"])} for row in rows] if categories: return categories legacy = parse_categories(await fetch_setting("ai_writer_categories", [])) - return legacy or [ + names = legacy or [ "защита", "одежда", "разгрузка", @@ -177,6 +187,7 @@ async def load_writer_categories(pool) -> list[str]: "аксессуары", "производство", ] + return [{"id": idx + 1, "name": name, "tag": normalize_hash_tag(name, "category")} for idx, name in enumerate(names)] class AIWriterWorker: @@ -316,11 +327,13 @@ class AIWriterWorker: rewrite_category=$7, rewrite_category_tag=$8, rewrite_source_tag=$9, + rewrite_category_id=$10, editorial_status='review', final_text=$2, final_category=$7, final_category_tag=$8, final_source_tag=$9, + final_category_id=$10, rewritten_at=NOW(), updated_at=NOW() WHERE id=$1 @@ -334,6 +347,7 @@ class AIWriterWorker: item["category"], item["category_tag"], item["source_tag"], + item["category_id"], ) async def call_ai(