Use category ids for AI writer
This commit is contained in:
@@ -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';
|
||||||
+31
-10
@@ -92,7 +92,7 @@ PROMPT_HINTS = {
|
|||||||
),
|
),
|
||||||
"safe": "Обычно не трогаем. Если промпт райтера переписывается, свободную часть меняем выше, контракт оставляем стабильным.",
|
"safe": "Обычно не трогаем. Если промпт райтера переписывается, свободную часть меняем выше, контракт оставляем стабильным.",
|
||||||
"input": "Контракт склеивается после свободной инструкции и отправляется как system prompt.",
|
"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]]:
|
async def category_rows() -> list[dict[str, Any]]:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
@@ -1040,7 +1059,12 @@ def prompt_test_preview_text(parsed: Any) -> str:
|
|||||||
|
|
||||||
def prompt_test_preview_category(parsed: Any) -> str:
|
def prompt_test_preview_category(parsed: Any) -> str:
|
||||||
rewrite = prompt_test_first_rewrite(parsed)
|
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:
|
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")
|
return redirect("/login")
|
||||||
q_status = normalize_prompt_test_status(q_status)
|
q_status = normalize_prompt_test_status(q_status)
|
||||||
posts, selected = await prompt_test_posts(post_id, 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))
|
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 "")
|
prompt = str(await fetch_setting("ai_writer_prompt", "") or "")
|
||||||
provider = str(await fetch_setting("ai_writer_provider", "anthropic") or "anthropic")
|
provider = str(await fetch_setting("ai_writer_provider", "anthropic") or "anthropic")
|
||||||
model = str(await fetch_setting("ai_writer_model", "") or "")
|
model = str(await fetch_setting("ai_writer_model", "") or "")
|
||||||
payload = prompt_test_payload(selected, max_text_chars) if selected else []
|
payload = prompt_test_payload(selected, max_text_chars) if selected else []
|
||||||
if payload:
|
if payload:
|
||||||
payload["categories"] = categories
|
payload["categories"] = category_payload
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"prompt_test.html",
|
"prompt_test.html",
|
||||||
base_context(
|
base_context(
|
||||||
@@ -2379,7 +2403,7 @@ async def prompt_test_run(
|
|||||||
require_csrf(user, csrf_token)
|
require_csrf(user, csrf_token)
|
||||||
q_status = normalize_prompt_test_status(q_status)
|
q_status = normalize_prompt_test_status(q_status)
|
||||||
posts, selected = await prompt_test_posts(post_id, 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))
|
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")
|
provider = str(await fetch_setting("ai_writer_provider", "anthropic") or "anthropic")
|
||||||
model = str(await fetch_setting("ai_writer_model", "") or "").strip()
|
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()
|
normalized_prompt = prompt.replace("\\r\\n", "\n").replace("\\n", "\n").strip()
|
||||||
payload = prompt_test_payload(selected, max_text_chars) if selected else []
|
payload = prompt_test_payload(selected, max_text_chars) if selected else []
|
||||||
if payload:
|
if payload:
|
||||||
payload["categories"] = categories
|
payload["categories"] = category_payload
|
||||||
result = None
|
result = None
|
||||||
error = None
|
error = None
|
||||||
if not selected:
|
if not selected:
|
||||||
@@ -2533,7 +2557,7 @@ async def category_create(
|
|||||||
return redirect("/workers")
|
return redirect("/workers")
|
||||||
tag = normalize_hash_tag(tag or name, "category")
|
tag = normalize_hash_tag(tag or name, "category")
|
||||||
pool = await get_pool()
|
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(
|
await pool.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO content_categories(name, tag, sort_order)
|
INSERT INTO content_categories(name, tag, sort_order)
|
||||||
@@ -2558,7 +2582,6 @@ async def category_update(
|
|||||||
csrf_token: str = Form(...),
|
csrf_token: str = Form(...),
|
||||||
name: str = Form(...),
|
name: str = Form(...),
|
||||||
tag: str = Form(""),
|
tag: str = Form(""),
|
||||||
sort_order: int = Form(0),
|
|
||||||
):
|
):
|
||||||
user = await get_current_user(request)
|
user = await get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -2574,14 +2597,12 @@ async def category_update(
|
|||||||
UPDATE content_categories
|
UPDATE content_categories
|
||||||
SET name=$2,
|
SET name=$2,
|
||||||
tag=$3,
|
tag=$3,
|
||||||
sort_order=$4,
|
|
||||||
updated_at=NOW()
|
updated_at=NOW()
|
||||||
WHERE id=$1
|
WHERE id=$1
|
||||||
""",
|
""",
|
||||||
category_id,
|
category_id,
|
||||||
name,
|
name,
|
||||||
tag,
|
tag,
|
||||||
sort_order,
|
|
||||||
)
|
)
|
||||||
await audit(user["id"], "category.update", "content_category", category_id, {"name": name, "tag": tag})
|
await audit(user["id"], "category.update", "content_category", category_id, {"name": name, "tag": tag})
|
||||||
return redirect("/workers")
|
return redirect("/workers")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<details class="panel" open>
|
<details class="panel" open>
|
||||||
<summary><strong>Категории публикаций</strong></summary>
|
<summary><strong>Категории публикаций</strong></summary>
|
||||||
<p class="muted">Активные категории попадают в выпадающий список редактора и передаются AI-райтеру. Удаление здесь выключает категорию, но не ломает старые посты.</p>
|
<p class="muted">Активные категории попадают в редакторскую и передаются AI-райтеру как ID, название и тэг. ID стабилен: модель возвращает его в JSON, а тэг подставляется из БД.</p>
|
||||||
<form class="category-create" method="post" action="/categories/create">
|
<form class="category-create" method="post" action="/categories/create">
|
||||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
<label><span>Название</span><input name="name" placeholder="например, разгрузка" required></label>
|
<label><span>Название</span><input name="name" placeholder="например, разгрузка" required></label>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<button class="btn primary" type="submit">Добавить</button>
|
<button class="btn primary" type="submit">Добавить</button>
|
||||||
</form>
|
</form>
|
||||||
<table class="compact-table category-table">
|
<table class="compact-table category-table">
|
||||||
<tr><th>Название</th><th>Тэг</th><th>Порядок</th><th>Статус</th><th></th></tr>
|
<tr><th>Название</th><th>Тэг</th><th>ID</th><th>Статус</th><th></th></tr>
|
||||||
{% for c in categories %}
|
{% for c in categories %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td><input form="category-update-{{ c.id }}" name="tag" value="{{ c.tag }}"></td>
|
<td><input form="category-update-{{ c.id }}" name="tag" value="{{ c.tag }}"></td>
|
||||||
<td><input form="category-update-{{ c.id }}" name="sort_order" type="number" value="{{ c.sort_order }}"></td>
|
<td><input value="{{ c.sort_order }}" type="number" readonly title="Стабильный ID категории для AI-райтера"></td>
|
||||||
<td>{% if c.is_active %}<span class="pill ok">активна</span>{% else %}<span class="pill">выключена</span>{% endif %}</td>
|
<td>{% if c.is_active %}<span class="pill ok">активна</span>{% else %}<span class="pill">выключена</span>{% endif %}</td>
|
||||||
<td class="icon-actions">
|
<td class="icon-actions">
|
||||||
<button class="icon-btn" form="category-update-{{ c.id }}" type="submit" title="Сохранить">✓</button>
|
<button class="icon-btn" form="category-update-{{ c.id }}" type="submit" title="Сохранить">✓</button>
|
||||||
|
|||||||
@@ -32,12 +32,14 @@ def normalize_prompt(prompt: str) -> str:
|
|||||||
|
|
||||||
DEFAULT_CONTRACT_PROMPT = """
|
DEFAULT_CONTRACT_PROMPT = """
|
||||||
OUTPUT SCHEMA (return array matching input order):
|
OUTPUT SCHEMA (return array matching input order):
|
||||||
{"rewrites":[{"id":123,"category":"разгрузка","text":"готовый текст без хэштегов","notes":"короткая заметка для редактора или null"}]}
|
{"rewrites":[{"id":123,"category_id":2,"text":"готовый текст без хэштегов","notes":"короткая заметка для редактора или null"}]}
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Input is a JSON object with key "posts" containing accepted posts.
|
- 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.
|
- 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 JSON only. No markdown. No text outside JSON.
|
||||||
- Return one rewrite for every input post id.
|
- 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 "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(
|
def validate_rewrites(
|
||||||
data: dict,
|
data: dict,
|
||||||
expected_ids: set[int],
|
expected_ids: set[int],
|
||||||
categories: list[str],
|
categories: list[dict[str, Any]],
|
||||||
source_tags_by_id: dict[int, str],
|
source_tags_by_id: dict[int, str],
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
rewrites = data.get("rewrites")
|
rewrites = data.get("rewrites")
|
||||||
@@ -127,18 +129,26 @@ def validate_rewrites(
|
|||||||
text = str(item.get("text") or "").strip()
|
text = str(item.get("text") or "").strip()
|
||||||
if len(text) < 40:
|
if len(text) < 40:
|
||||||
raise ValueError(f"AI returned too short rewrite for id={post_id}")
|
raise ValueError(f"AI returned too short rewrite for id={post_id}")
|
||||||
category = str(item.get("category") or "").strip().strip("#")
|
by_id = {int(c["id"]): c for c in categories if c.get("id") is not None}
|
||||||
category_map = {c.lower(): c for c in categories}
|
by_name = {str(c["name"]).lower(): c for c in categories if c.get("name")}
|
||||||
if category.lower() not in category_map:
|
if item.get("category_id") is not None:
|
||||||
raise ValueError(f"AI returned unknown category={category!r} for id={post_id}")
|
category_id = int(item["category_id"])
|
||||||
category = category_map[category.lower()]
|
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]
|
notes = str(item.get("notes") or "").strip()[:1000]
|
||||||
seen.add(post_id)
|
seen.add(post_id)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"id": post_id,
|
"id": post_id,
|
||||||
"category": category,
|
"category_id": int(category_row["id"]),
|
||||||
"category_tag": normalize_hash_tag(category, "category"),
|
"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"),
|
"source_tag": normalize_hash_tag(source_tags_by_id.get(post_id) or "", "source"),
|
||||||
"text": text,
|
"text": text,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
@@ -150,11 +160,11 @@ def validate_rewrites(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def load_writer_categories(pool) -> list[str]:
|
async def load_writer_categories(pool) -> list[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT name
|
SELECT sort_order AS id, name, tag
|
||||||
FROM content_categories
|
FROM content_categories
|
||||||
WHERE is_active=TRUE
|
WHERE is_active=TRUE
|
||||||
ORDER BY sort_order, name
|
ORDER BY sort_order, name
|
||||||
@@ -162,11 +172,11 @@ async def load_writer_categories(pool) -> list[str]:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
rows = []
|
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:
|
if categories:
|
||||||
return categories
|
return categories
|
||||||
legacy = parse_categories(await fetch_setting("ai_writer_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:
|
class AIWriterWorker:
|
||||||
@@ -316,11 +327,13 @@ class AIWriterWorker:
|
|||||||
rewrite_category=$7,
|
rewrite_category=$7,
|
||||||
rewrite_category_tag=$8,
|
rewrite_category_tag=$8,
|
||||||
rewrite_source_tag=$9,
|
rewrite_source_tag=$9,
|
||||||
|
rewrite_category_id=$10,
|
||||||
editorial_status='review',
|
editorial_status='review',
|
||||||
final_text=$2,
|
final_text=$2,
|
||||||
final_category=$7,
|
final_category=$7,
|
||||||
final_category_tag=$8,
|
final_category_tag=$8,
|
||||||
final_source_tag=$9,
|
final_source_tag=$9,
|
||||||
|
final_category_id=$10,
|
||||||
rewritten_at=NOW(),
|
rewritten_at=NOW(),
|
||||||
updated_at=NOW()
|
updated_at=NOW()
|
||||||
WHERE id=$1
|
WHERE id=$1
|
||||||
@@ -334,6 +347,7 @@ class AIWriterWorker:
|
|||||||
item["category"],
|
item["category"],
|
||||||
item["category_tag"],
|
item["category_tag"],
|
||||||
item["source_tag"],
|
item["source_tag"],
|
||||||
|
item["category_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def call_ai(
|
async def call_ai(
|
||||||
|
|||||||
Reference in New Issue
Block a user