Add qualifier prompt test page
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -26,6 +27,7 @@ from .db import fetch_setting, get_pool
|
|||||||
from .security import hash_password, new_token, token_hash, verify_password
|
from .security import hash_password, new_token, token_hash, verify_password
|
||||||
from .text_utils import normalize_hash_tag, parse_categories
|
from .text_utils import normalize_hash_tag, parse_categories
|
||||||
from .vk_api import VKAPIClient, normalize_vk_source
|
from .vk_api import VKAPIClient, normalize_vk_source
|
||||||
|
from .workers.ai_qualifier import normalize_model, response_usage
|
||||||
|
|
||||||
COOKIE_NAME = "vk_parser_admin"
|
COOKIE_NAME = "vk_parser_admin"
|
||||||
VK_OAUTH_VERIFIER_COOKIE = "vk_oauth_verifier"
|
VK_OAUTH_VERIFIER_COOKIE = "vk_oauth_verifier"
|
||||||
@@ -914,6 +916,71 @@ async def category_rows() -> list[dict[str, Any]]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def prompt_test_posts(selected_post_id: int | None = None) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"""
|
||||||
|
SELECT rp.id, rp.raw_text, rp.original_url, rp.posted_at, rp.created_at, rp.qualification_status,
|
||||||
|
rp.qualification_score, s.name AS source_name, s.tag AS source_tag,
|
||||||
|
(SELECT COUNT(*) FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_count,
|
||||||
|
(SELECT ARRAY_AGG(DISTINCT m.media_type ORDER BY m.media_type)
|
||||||
|
FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_types
|
||||||
|
FROM raw_posts rp
|
||||||
|
JOIN sources s ON s.id=rp.source_id
|
||||||
|
ORDER BY rp.created_at DESC, rp.id DESC
|
||||||
|
LIMIT 150
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
posts = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
text = str(item.get("raw_text") or "").strip()
|
||||||
|
item["created_at_fmt"] = format_dt(item.get("created_at"))
|
||||||
|
item["posted_at_fmt"] = format_dt(item.get("posted_at"))
|
||||||
|
item["label"] = f"#{item['id']} · {item.get('source_name') or 'source'} · {item['created_at_fmt']}"
|
||||||
|
item["snippet"] = text[:180] + ("..." if len(text) > 180 else "")
|
||||||
|
posts.append(item)
|
||||||
|
selected = next((item for item in posts if int(item["id"]) == int(selected_post_id or 0)), None)
|
||||||
|
if not selected and selected_post_id:
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT rp.id, rp.raw_text, rp.original_url, rp.posted_at, rp.created_at, rp.qualification_status,
|
||||||
|
rp.qualification_score, s.name AS source_name, s.tag AS source_tag,
|
||||||
|
(SELECT COUNT(*) FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_count,
|
||||||
|
(SELECT ARRAY_AGG(DISTINCT m.media_type ORDER BY m.media_type)
|
||||||
|
FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_types
|
||||||
|
FROM raw_posts rp
|
||||||
|
JOIN sources s ON s.id=rp.source_id
|
||||||
|
WHERE rp.id=$1
|
||||||
|
""",
|
||||||
|
selected_post_id,
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
selected = dict(row)
|
||||||
|
text = str(selected.get("raw_text") or "").strip()
|
||||||
|
selected["created_at_fmt"] = format_dt(selected.get("created_at"))
|
||||||
|
selected["posted_at_fmt"] = format_dt(selected.get("posted_at"))
|
||||||
|
selected["label"] = f"#{selected['id']} · {selected.get('source_name') or 'source'} · {selected['created_at_fmt']}"
|
||||||
|
selected["snippet"] = text[:180] + ("..." if len(text) > 180 else "")
|
||||||
|
posts.insert(0, selected)
|
||||||
|
if not selected and posts:
|
||||||
|
selected = posts[0]
|
||||||
|
return posts, selected
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_test_payload(post: dict[str, Any], max_text_chars: int) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": int(post["id"]),
|
||||||
|
"source": post.get("source_name") or "",
|
||||||
|
"original_url": post.get("original_url") or "",
|
||||||
|
"media_count": int(post.get("media_count") or 0),
|
||||||
|
"media_types": list(post.get("media_types") or []),
|
||||||
|
"text": str(post.get("raw_text") or "")[:max_text_chars],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def setting_value(settings_rows: list[dict], key: str, default: Any = None) -> Any:
|
def setting_value(settings_rows: list[dict], key: str, default: Any = None) -> Any:
|
||||||
for row in settings_rows:
|
for row in settings_rows:
|
||||||
if row.get("key") == key:
|
if row.get("key") == key:
|
||||||
@@ -2195,6 +2262,121 @@ async def raw_post_detail(request: Request, post_id: int):
|
|||||||
return templates.TemplateResponse("raw_post_detail.html", base_context(request, user, post=post))
|
return templates.TemplateResponse("raw_post_detail.html", base_context(request, user, post=post))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/prompt-test", response_class=HTMLResponse)
|
||||||
|
async def prompt_test(request: Request, post_id: int | None = None):
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return redirect("/login")
|
||||||
|
posts, selected = await prompt_test_posts(post_id)
|
||||||
|
max_text_chars = max(100, int(await fetch_setting("ai_qualifier_max_text_chars", 2000) or 2000))
|
||||||
|
prompt = str(await fetch_setting("ai_qualifier_prompt", "") or "")
|
||||||
|
provider = str(await fetch_setting("ai_qualifier_provider", "openrouter") or "openrouter")
|
||||||
|
model = str(await fetch_setting("ai_qualifier_model", "") or "")
|
||||||
|
payload = prompt_test_payload(selected, max_text_chars) if selected else []
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"prompt_test.html",
|
||||||
|
base_context(
|
||||||
|
request,
|
||||||
|
user,
|
||||||
|
posts=posts,
|
||||||
|
selected=selected,
|
||||||
|
prompt=prompt,
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
max_text_chars=max_text_chars,
|
||||||
|
payload_json=json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
|
result=None,
|
||||||
|
error=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/prompt-test", response_class=HTMLResponse)
|
||||||
|
async def prompt_test_run(
|
||||||
|
request: Request,
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
post_id: int = Form(...),
|
||||||
|
prompt: str = Form(...),
|
||||||
|
):
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return redirect("/login")
|
||||||
|
require_csrf(user, csrf_token)
|
||||||
|
posts, selected = await prompt_test_posts(post_id)
|
||||||
|
max_text_chars = max(100, int(await fetch_setting("ai_qualifier_max_text_chars", 2000) or 2000))
|
||||||
|
provider = str(await fetch_setting("ai_qualifier_provider", "openrouter") or "openrouter")
|
||||||
|
model = str(await fetch_setting("ai_qualifier_model", "") or "").strip()
|
||||||
|
api_key = str(await fetch_setting("ai_qualifier_api_key", "") or "").strip()
|
||||||
|
api_base = str(await fetch_setting("ai_qualifier_api_base", "") or "").strip()
|
||||||
|
temperature = max(0.0, float(await fetch_setting("ai_qualifier_temperature", 0.0) or 0.0))
|
||||||
|
timeout = max(10, int(await fetch_setting("ai_qualifier_timeout_sec", 120) or 120))
|
||||||
|
normalized_prompt = prompt.replace("\\r\\n", "\n").replace("\\n", "\n").strip()
|
||||||
|
payload = prompt_test_payload(selected, max_text_chars) if selected else []
|
||||||
|
result = None
|
||||||
|
error = None
|
||||||
|
if not selected:
|
||||||
|
error = "Пост не найден."
|
||||||
|
elif not model or not api_key:
|
||||||
|
error = "Не заполнены модель или API-ключ квалификатора в настройках."
|
||||||
|
elif not normalized_prompt:
|
||||||
|
error = "Промпт пустой."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"model": normalize_model(provider, model),
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": normalized_prompt},
|
||||||
|
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
||||||
|
],
|
||||||
|
"temperature": temperature,
|
||||||
|
"timeout": timeout,
|
||||||
|
}
|
||||||
|
if api_key:
|
||||||
|
kwargs["api_key"] = api_key
|
||||||
|
if api_base:
|
||||||
|
kwargs["api_base"] = api_base
|
||||||
|
response = await asyncio.to_thread(litellm.completion, **kwargs)
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
parsed = None
|
||||||
|
raw_json = str(content or "").strip()
|
||||||
|
try:
|
||||||
|
if raw_json.startswith("```"):
|
||||||
|
raw_json = raw_json.strip("`")
|
||||||
|
if raw_json.lower().startswith("json"):
|
||||||
|
raw_json = raw_json[4:].strip()
|
||||||
|
parsed = json.loads(raw_json)
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
result = {
|
||||||
|
"content": content,
|
||||||
|
"parsed_json": json.dumps(parsed, ensure_ascii=False, indent=2) if parsed is not None else "",
|
||||||
|
"usage": response_usage(response),
|
||||||
|
"model": kwargs["model"],
|
||||||
|
}
|
||||||
|
await audit(user["id"], "prompt_test.run", "raw_post", int(selected["id"]), {"provider": provider, "model": model})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Prompt test failed")
|
||||||
|
error = str(exc)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"prompt_test.html",
|
||||||
|
base_context(
|
||||||
|
request,
|
||||||
|
user,
|
||||||
|
posts=posts,
|
||||||
|
selected=selected,
|
||||||
|
prompt=normalized_prompt,
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
max_text_chars=max_text_chars,
|
||||||
|
payload_json=json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
|
result=result,
|
||||||
|
error=error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/workers", response_class=HTMLResponse)
|
@app.get("/workers", response_class=HTMLResponse)
|
||||||
async def workers(request: Request):
|
async def workers(request: Request):
|
||||||
user = await get_current_user(request)
|
user = await get_current_user(request)
|
||||||
|
|||||||
@@ -117,6 +117,14 @@
|
|||||||
.prompt-hint { max-width:720px; border:1px solid var(--line); border-radius:8px; padding:10px 12px; background:#f8fafc; display:grid; gap:8px; color:var(--text); }
|
.prompt-hint { max-width:720px; border:1px solid var(--line); border-radius:8px; padding:10px 12px; background:#f8fafc; display:grid; gap:8px; color:var(--text); }
|
||||||
.prompt-hint summary { cursor:pointer; font-weight:700; }
|
.prompt-hint summary { cursor:pointer; font-weight:700; }
|
||||||
.prompt-hint pre, .prompt-box { white-space:pre-wrap; overflow:auto; padding:10px; border:1px solid var(--line); border-radius:8px; background:#f8fafc; color:var(--text); }
|
.prompt-hint pre, .prompt-box { white-space:pre-wrap; overflow:auto; padding:10px; border:1px solid var(--line); border-radius:8px; background:#f8fafc; color:var(--text); }
|
||||||
|
.prompt-test-layout { display:grid; grid-template-columns:minmax(0, 1fr) 420px; gap:16px; align-items:start; }
|
||||||
|
.prompt-test-main { margin-bottom:0; }
|
||||||
|
.prompt-test-side { display:grid; gap:16px; position:sticky; top:74px; max-height:calc(100vh - 92px); overflow:auto; }
|
||||||
|
.prompt-test-model { min-height:38px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||||
|
.prompt-test-post { margin:12px 0 14px; border:1px solid var(--line); border-radius:8px; padding:12px; background:var(--panel-soft); }
|
||||||
|
.prompt-test-post-head { display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:4px; }
|
||||||
|
.prompt-test-post pre { max-height:260px; overflow:auto; margin:10px 0 0; white-space:pre-wrap; font:inherit; color:var(--text); }
|
||||||
|
.prompt-test-help p { margin:0; }
|
||||||
.hint-grid { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:10px; }
|
.hint-grid { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:10px; }
|
||||||
.hint-label { color:var(--muted); font-size:12px; font-weight:700; text-transform:uppercase; margin-bottom:3px; }
|
.hint-label { color:var(--muted); font-size:12px; font-weight:700; text-transform:uppercase; margin-bottom:3px; }
|
||||||
.small-help { font-size:12px; margin-top:4px; }
|
.small-help { font-size:12px; margin-top:4px; }
|
||||||
@@ -208,7 +216,7 @@
|
|||||||
.regen-form { margin-top:16px; border-top:1px solid var(--line); padding-top:14px; }
|
.regen-form { margin-top:16px; border-top:1px solid var(--line); padding-top:14px; }
|
||||||
.compact-kv { margin-top:12px; grid-template-columns:90px minmax(0,1fr); }
|
.compact-kv { margin-top:12px; grid-template-columns:90px minmax(0,1fr); }
|
||||||
.empty-state { text-align:center; color:var(--muted); padding:34px; }
|
.empty-state { text-align:center; color:var(--muted); padding:34px; }
|
||||||
@media (max-width: 900px) { header { padding:8px 12px; align-items:flex-start; } .filter-grid, .sources-filter, .source-add-grid, .hint-grid, .advanced-grid, .tool-row, .tool-row.sort-row, .editor-filter, .page-head, .compare-grid, .dialog-grid, .catalog-layout, .media-upload-form, .category-create { grid-template-columns:1fr; } .page-head { display:grid; } .form-grid { grid-template-columns:1fr; } .detail-grid { grid-template-columns:1fr; } .editor-card { grid-template-columns:1fr; } .editor-actions { grid-template-columns:repeat(3, minmax(0,1fr)); } .context-pane { border-right:0; border-bottom:1px solid var(--line); max-height:none; } .edit-pane { max-height:none; } .side-panel, .filter-sidebar { position:static; max-height:none; } .list-toolbar { display:grid; align-items:start; } .compact-controls { justify-content:start; } main { padding:14px; } }
|
@media (max-width: 900px) { header { padding:8px 12px; align-items:flex-start; } .filter-grid, .sources-filter, .source-add-grid, .hint-grid, .advanced-grid, .tool-row, .tool-row.sort-row, .editor-filter, .page-head, .compare-grid, .dialog-grid, .catalog-layout, .media-upload-form, .category-create, .prompt-test-layout { grid-template-columns:1fr; } .page-head { display:grid; } .form-grid { grid-template-columns:1fr; } .detail-grid { grid-template-columns:1fr; } .editor-card { grid-template-columns:1fr; } .editor-actions { grid-template-columns:repeat(3, minmax(0,1fr)); } .context-pane { border-right:0; border-bottom:1px solid var(--line); max-height:none; } .edit-pane { max-height:none; } .side-panel, .filter-sidebar, .prompt-test-side { position:static; max-height:none; } .list-toolbar { display:grid; align-items:start; } .compact-controls { justify-content:start; } main { padding:14px; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -218,6 +226,7 @@
|
|||||||
<a href="/sources">Источники</a>
|
<a href="/sources">Источники</a>
|
||||||
<a href="/raw">Raw</a>
|
<a href="/raw">Raw</a>
|
||||||
<a href="/editor">Редакторская</a>
|
<a href="/editor">Редакторская</a>
|
||||||
|
<a href="/prompt-test">Тест промптов</a>
|
||||||
<a href="/workers">Воркеры</a>
|
<a href="/workers">Воркеры</a>
|
||||||
<a href="/users">Пользователи</a>
|
<a href="/users">Пользователи</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Тест промптов</h1>
|
||||||
|
<p class="muted">Песочница для свободной части <span class="mono">ai_qualifier_prompt</span>. Посты и оценки в БД не меняются.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn" href="/workers">Настройки AI</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="panel prompt-test-help" open>
|
||||||
|
<summary><strong>Как пользоваться</strong></summary>
|
||||||
|
<div class="hint-grid">
|
||||||
|
<div>
|
||||||
|
<div class="hint-label">Что тестируем</div>
|
||||||
|
<p>Отправляется только текст из поля ниже как system prompt. <span class="mono">ai_qualifier_contract</span> специально не добавляется, чтобы можно было быстро проверять идеи, тон и критерии.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="hint-label">Что уйдёт в модель</div>
|
||||||
|
<p>Один выбранный raw-пост в том же формате, что использует квалификатор: <span class="mono">id</span>, источник, ссылка, количество медиа, типы медиа и обрезанный текст.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="hint-label">Что безопасно менять</div>
|
||||||
|
<p>Роль, критерии, примеры хороших/плохих постов, шкалу оценки и формулировки причин. Для рабочего воркера JSON-контракт всё равно живёт отдельно.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="hint-label">Что не происходит</div>
|
||||||
|
<p>Статус поста не меняется, batch не создаётся, оценка не сохраняется. Тест только тратит токены текущей модели квалификатора.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form class="prompt-test-layout" method="post" action="/prompt-test">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<section class="panel prompt-test-main">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Пост для теста</span>
|
||||||
|
<select name="post_id" data-post-select>
|
||||||
|
{% for post in posts %}
|
||||||
|
<option value="{{ post.id }}" {% if selected and post.id == selected.id %}selected{% endif %}>{{ post.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span class="muted">Модель</span>
|
||||||
|
<div class="prompt-test-model"><span class="pill">{{ provider }}</span><span class="mono">{{ model or "не выбрана" }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if selected %}
|
||||||
|
<div class="prompt-test-post">
|
||||||
|
<div class="prompt-test-post-head">
|
||||||
|
<strong>#{{ selected.id }} · {{ selected.source_name }}</strong>
|
||||||
|
<a href="{{ selected.original_url }}" target="_blank" rel="noopener">Оригинал</a>
|
||||||
|
</div>
|
||||||
|
<div class="muted">{{ selected.posted_at_fmt or selected.created_at_fmt }} · {{ selected.media_count }} медиа · {{ selected.media_types or [] }}</div>
|
||||||
|
<pre>{{ selected.raw_text }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>ai_qualifier_prompt для теста</span>
|
||||||
|
<textarea name="prompt" rows="18" spellcheck="false">{{ prompt }}</textarea>
|
||||||
|
</label>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn primary" type="submit">Отправить тест</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="prompt-test-side">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Payload</h2>
|
||||||
|
<p class="muted">Именно это отправится user-сообщением. Текст обрезан по настройке <span class="mono">ai_qualifier_max_text_chars={{ max_text_chars }}</span>.</p>
|
||||||
|
<pre class="json-box">{{ payload_json }}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Ответ</h2>
|
||||||
|
{% if error %}
|
||||||
|
<div class="pill bad">{{ error }}</div>
|
||||||
|
{% elif result %}
|
||||||
|
<dl class="kv compact-kv">
|
||||||
|
<dt>Модель</dt><dd class="mono">{{ result.model }}</dd>
|
||||||
|
<dt>Токены</dt><dd>{{ result.usage.total_tokens or "—" }} total, {{ result.usage.prompt_tokens or "—" }} prompt, {{ result.usage.completion_tokens or "—" }} completion</dd>
|
||||||
|
<dt>Стоимость</dt><dd>{{ result.usage.estimated_cost_usd or "—" }}</dd>
|
||||||
|
</dl>
|
||||||
|
{% if result.parsed_json %}
|
||||||
|
<h3>JSON разобран</h3>
|
||||||
|
<pre class="json-box">{{ result.parsed_json }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
<h3>Сырой ответ</h3>
|
||||||
|
<pre class="json-box">{{ result.content }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">После отправки здесь появится ответ модели.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
document.querySelector("[data-post-select]")?.addEventListener("change", (event) => {
|
||||||
|
const id = event.target.value;
|
||||||
|
if (id) window.location.href = `/prompt-test?post_id=${encodeURIComponent(id)}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user