diff --git a/src/vk_parser_app/admin.py b/src/vk_parser_app/admin.py index 477b834..c45df31 100644 --- a/src/vk_parser_app/admin.py +++ b/src/vk_parser_app/admin.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import base64 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 .text_utils import normalize_hash_tag, parse_categories from .vk_api import VKAPIClient, normalize_vk_source +from .workers.ai_qualifier import normalize_model, response_usage COOKIE_NAME = "vk_parser_admin" VK_OAUTH_VERIFIER_COOKIE = "vk_oauth_verifier" @@ -914,6 +916,71 @@ async def category_rows() -> list[dict[str, Any]]: 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: for row in settings_rows: 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)) +@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) async def workers(request: Request): user = await get_current_user(request) diff --git a/src/vk_parser_app/templates/base.html b/src/vk_parser_app/templates/base.html index 7413b8a..afe8cff 100644 --- a/src/vk_parser_app/templates/base.html +++ b/src/vk_parser_app/templates/base.html @@ -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 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-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-label { color:var(--muted); font-size:12px; font-weight:700; text-transform:uppercase; margin-bottom:3px; } .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; } .compact-kv { margin-top:12px; grid-template-columns:90px minmax(0,1fr); } .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; } } @@ -218,6 +226,7 @@ Источники Raw Редакторская + Тест промптов Воркеры Пользователи diff --git a/src/vk_parser_app/templates/prompt_test.html b/src/vk_parser_app/templates/prompt_test.html new file mode 100644 index 0000000..4b21d4a --- /dev/null +++ b/src/vk_parser_app/templates/prompt_test.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} +{% block body %} +
+
+

Тест промптов

+

Песочница для свободной части ai_qualifier_prompt. Посты и оценки в БД не меняются.

+
+ Настройки AI +
+ +
+ Как пользоваться +
+
+
Что тестируем
+

Отправляется только текст из поля ниже как system prompt. ai_qualifier_contract специально не добавляется, чтобы можно было быстро проверять идеи, тон и критерии.

+
+
+
Что уйдёт в модель
+

Один выбранный raw-пост в том же формате, что использует квалификатор: id, источник, ссылка, количество медиа, типы медиа и обрезанный текст.

+
+
+
Что безопасно менять
+

Роль, критерии, примеры хороших/плохих постов, шкалу оценки и формулировки причин. Для рабочего воркера JSON-контракт всё равно живёт отдельно.

+
+
+
Что не происходит
+

Статус поста не меняется, batch не создаётся, оценка не сохраняется. Тест только тратит токены текущей модели квалификатора.

+
+
+
+ +
+ +
+
+ +
+ Модель +
{{ provider }}{{ model or "не выбрана" }}
+
+
+ + {% if selected %} +
+
+ #{{ selected.id }} · {{ selected.source_name }} + Оригинал +
+
{{ selected.posted_at_fmt or selected.created_at_fmt }} · {{ selected.media_count }} медиа · {{ selected.media_types or [] }}
+
{{ selected.raw_text }}
+
+ {% endif %} + + +
+ +
+
+ + +
+ +{% endblock %}