From 8250304d02c48520fbab822a9aa87cfe27235d5e Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 16 Jun 2026 22:05:31 +0500 Subject: [PATCH] Improve writer prompt testing --- .../018_writer_prompt_contract_cleanup.sql | 25 ++++++ src/vk_parser_app/admin.py | 79 +++++++++++++++++-- src/vk_parser_app/templates/base.html | 4 +- src/vk_parser_app/templates/prompt_test.html | 25 +++++- 4 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 db/migrations/018_writer_prompt_contract_cleanup.sql diff --git a/db/migrations/018_writer_prompt_contract_cleanup.sql b/db/migrations/018_writer_prompt_contract_cleanup.sql new file mode 100644 index 0000000..df004d1 --- /dev/null +++ b/db/migrations/018_writer_prompt_contract_cleanup.sql @@ -0,0 +1,25 @@ +UPDATE app_settings +SET value_json = to_jsonb( + trim( + regexp_replace( + value_json #>> '{}', + E'\\n?OUTPUT:[[:space:]]*JSON only\\.[[:space:]]*', + E'\\n', + 'gi' + ) + ) +), +updated_at = NOW() +WHERE key = 'ai_writer_prompt' + AND value_json #>> '{}' ~* 'OUTPUT:[[:space:]]*JSON only\.'; + +UPDATE app_settings +SET value_json = to_jsonb( + CASE + WHEN value_json #>> '{}' ~* 'Return JSON only\\.' + THEN value_json #>> '{}' + ELSE trim(value_json #>> '{}') || E'\n- Return JSON only. No markdown. No text outside JSON.' + END +), +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 6a5d45a..52d1701 100644 --- a/src/vk_parser_app/admin.py +++ b/src/vk_parser_app/admin.py @@ -916,10 +916,39 @@ 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]: +PROMPT_TEST_STATUS_OPTIONS = [ + {"value": "all", "label": "Все"}, + {"value": "qualified", "label": "Оцененные"}, + {"value": "accepted", "label": "Принятые"}, + {"value": "maybe", "label": "Спорные"}, + {"value": "rejected", "label": "Отклоненные"}, + {"value": "pending", "label": "Ждут AI"}, +] + + +def normalize_prompt_test_status(value: str | None) -> str: + value = str(value or "all").strip().lower() + allowed = {item["value"] for item in PROMPT_TEST_STATUS_OPTIONS} + return value if value in allowed else "all" + + +def prompt_test_status_where(status_filter: str) -> str: + if status_filter == "qualified": + return "AND rp.qualification_status IN ('accepted', 'maybe', 'rejected')" + if status_filter in {"accepted", "maybe", "rejected", "pending"}: + return f"AND rp.qualification_status = '{status_filter}'" + return "" + + +async def prompt_test_posts( + selected_post_id: int | None = None, + status_filter: str = "all", +) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: pool = await get_pool() + status_filter = normalize_prompt_test_status(status_filter) + where_sql = prompt_test_status_where(status_filter) rows = await pool.fetch( - """ + f""" 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, rp.qualification_reason, @@ -928,6 +957,8 @@ async def prompt_test_posts(selected_post_id: int | None = None) -> tuple[list[d 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 TRUE + {where_sql} ORDER BY rp.created_at DESC, rp.id DESC LIMIT 150 """ @@ -990,6 +1021,34 @@ def prompt_test_payload(post: dict[str, Any], max_text_chars: int) -> dict[str, } +def prompt_test_first_rewrite(parsed: Any) -> dict[str, Any] | None: + if not isinstance(parsed, dict): + return None + rewrites = parsed.get("rewrites") + if not isinstance(rewrites, list) and isinstance(parsed.get("data"), dict): + rewrites = parsed["data"].get("rewrites") + if not isinstance(rewrites, list) or not rewrites: + return None + first = rewrites[0] + return first if isinstance(first, dict) else None + + +def prompt_test_preview_text(parsed: Any) -> str: + rewrite = prompt_test_first_rewrite(parsed) + return str((rewrite or {}).get("text") or "").strip() + + +def prompt_test_preview_category(parsed: Any) -> str: + rewrite = prompt_test_first_rewrite(parsed) + return str((rewrite or {}).get("category") or "").strip() + + +def prompt_test_preview_notes(parsed: Any) -> str: + rewrite = prompt_test_first_rewrite(parsed) + notes = (rewrite or {}).get("notes") + return "" if notes in {None, ""} else str(notes).strip() + + def setting_value(settings_rows: list[dict], key: str, default: Any = None) -> Any: for row in settings_rows: if row.get("key") == key: @@ -2272,11 +2331,12 @@ async def raw_post_detail(request: Request, post_id: int): @app.get("/prompt-test", response_class=HTMLResponse) -async def prompt_test(request: Request, post_id: int | None = None): +async def prompt_test(request: Request, post_id: int | None = None, q_status: str = "all"): user = await get_current_user(request) if not user: return redirect("/login") - posts, selected = await prompt_test_posts(post_id) + q_status = normalize_prompt_test_status(q_status) + posts, selected = await prompt_test_posts(post_id, q_status) categories = await writer_categories() 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 "") @@ -2295,6 +2355,8 @@ async def prompt_test(request: Request, post_id: int | None = None): prompt=prompt, provider=provider, model=model, + q_status=q_status, + status_options=PROMPT_TEST_STATUS_OPTIONS, max_text_chars=max_text_chars, payload_json=json.dumps(payload, ensure_ascii=False, indent=2), result=None, @@ -2309,12 +2371,14 @@ async def prompt_test_run( csrf_token: str = Form(...), post_id: int = Form(...), prompt: str = Form(...), + q_status: str = Form("all"), ): 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) + q_status = normalize_prompt_test_status(q_status) + posts, selected = await prompt_test_posts(post_id, q_status) categories = await writer_categories() 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") @@ -2367,6 +2431,9 @@ async def prompt_test_run( result = { "content": content, "parsed_json": json.dumps(parsed, ensure_ascii=False, indent=2) if parsed is not None else "", + "preview_text": prompt_test_preview_text(parsed), + "preview_category": prompt_test_preview_category(parsed), + "preview_notes": prompt_test_preview_notes(parsed), "usage": response_usage(response), "model": kwargs["model"], } @@ -2384,6 +2451,8 @@ async def prompt_test_run( prompt=normalized_prompt, provider=provider, model=model, + q_status=q_status, + status_options=PROMPT_TEST_STATUS_OPTIONS, max_text_chars=max_text_chars, payload_json=json.dumps(payload, ensure_ascii=False, indent=2), result=result, diff --git a/src/vk_parser_app/templates/base.html b/src/vk_parser_app/templates/base.html index afe8cff..3bdd5eb 100644 --- a/src/vk_parser_app/templates/base.html +++ b/src/vk_parser_app/templates/base.html @@ -118,6 +118,7 @@ .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-controls { display:grid; grid-template-columns:150px minmax(260px, 1fr) minmax(220px, .7fr); gap:12px; align-items:end; } .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; } @@ -125,6 +126,7 @@ .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; } + .human-answer { white-space:pre-wrap; overflow:auto; max-height:360px; padding:12px; border:1px solid #b7caf8; border-radius:8px; background:#f8fbff; color:var(--text); font:16px/1.55 Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; } .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; } @@ -216,7 +218,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, .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; } } + @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, .prompt-test-controls { 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; } } diff --git a/src/vk_parser_app/templates/prompt_test.html b/src/vk_parser_app/templates/prompt_test.html index ba22065..158a3b7 100644 --- a/src/vk_parser_app/templates/prompt_test.html +++ b/src/vk_parser_app/templates/prompt_test.html @@ -33,7 +33,15 @@
-
+
+