From 9cee862c326ee2678fb7c9ee52b4ca8ef4c7c1bc Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 15 Jun 2026 07:50:49 +0200 Subject: [PATCH] Implement Planning Prompt Enhancements and LLM Usage Tracking - Added new fields for goal query, user notes, max steps, and search query in the AiPromptPreviewBody to support planning prompts. - Integrated planning prompt handling in the preview_ai_prompt function, allowing for distinct processing of planning and exercise prompts. - Introduced LLM usage tracking in openrouter_chat_completion and planning_exercise_suggest functions to monitor AI call metrics. - Updated frontend components to accommodate new input fields for planning prompts, enhancing user experience and functionality. --- backend/ai_prompt_planning_preview.py | 272 ++++++++++++++++++ backend/openrouter_chat.py | 7 + backend/planning_llm_usage.py | 62 ++++ backend/routers/ai_prompts_admin.py | 23 +- backend/routers/planning_exercise_suggest.py | 21 +- .../tests/test_ai_prompt_planning_preview.py | 89 ++++++ backend/tests/test_planning_llm_usage.py | 94 ++++++ .../src/components/ProgressionGraphEditor.jsx | 23 -- frontend/src/pages/AdminAiPromptsPage.jsx | 154 +++++++--- 9 files changed, 679 insertions(+), 66 deletions(-) create mode 100644 backend/ai_prompt_planning_preview.py create mode 100644 backend/planning_llm_usage.py create mode 100644 backend/tests/test_ai_prompt_planning_preview.py create mode 100644 backend/tests/test_planning_llm_usage.py diff --git a/backend/ai_prompt_planning_preview.py b/backend/ai_prompt_planning_preview.py new file mode 100644 index 0000000..a7188fb --- /dev/null +++ b/backend/ai_prompt_planning_preview.py @@ -0,0 +1,272 @@ +""" +Admin-Vorschau: Platzhalter für Planungs-Prompts (Progressionsgraph, Pfad-QS, Suggest). + +Nutzt repräsentative Beispieldaten + echte Katalog-Auszüge aus der DB. +""" +from __future__ import annotations + +import json +from typing import Any, Dict, List, Mapping, Optional + +from pydantic import BaseModel, Field + +from planning_exercise_semantics import brief_to_summary_dict, build_semantic_brief +from planning_intent_context import build_planning_intent_context + +PLANNING_PROMPT_SLUGS = frozenset( + { + "planning_progression_start_target", + "planning_progression_goal_analysis", + "planning_progression_roadmap", + "planning_progression_stage_spec", + "planning_exercise_query_semantics", + "planning_exercise_path_qa", + "planning_exercise_search_intent", + "planning_exercise_search_rank", + "planning_exercise_expectation_profile", + } +) + + +class PlanningPromptPreviewInput(BaseModel): + goal_query: str = Field( + default="Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe", + max_length=2000, + ) + user_notes: str = Field(default="Fokus Breitensport, ohne Wettkampfdruck.", max_length=2000) + max_steps: int = Field(default=5, ge=2, le=10) + search_query: Optional[str] = Field(default=None, max_length=2000) + + +def is_planning_prompt_slug(slug: str) -> bool: + return (slug or "").strip().lower() in PLANNING_PROMPT_SLUGS + + +def _compact_json(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + +def _sample_goal_analysis() -> Dict[str, Any]: + return { + "primary_topic": "Mae Geri", + "start_assumption": "Grundstellung und einfache Frontkick-Bewegung bekannt", + "target_state": "Kontrollierter Mae Geri in Kumite-Nähe mit Hüftöffnung", + "success_criteria": [ + "Hüfte öffnet vor dem Kick", + "Ballen trifft Zielzone", + "Rückzug ohne Balanceverlust", + ], + "constraints": { + "partner_required": False, + "excluded_themes": ["reine Kraft ohne Technikbezug"], + "trainer_notes": "Breitensport, kein Wettkampf", + }, + } + + +def _sample_major_steps(max_steps: int) -> List[Dict[str, Any]]: + phases = ["einstieg", "grundlage", "vertiefung", "anwendung", "perfektion"] + titles = [ + "Grundstellung und Mae Geri Einstieg", + "Hüftöffnung und Ballen-Fokus", + "Koordination und Rückzug", + "Anwendung in Partnerübung", + "Qualität unter leichtem Druck", + ] + out: List[Dict[str, Any]] = [] + for i in range(max_steps): + out.append( + { + "index": i, + "phase": phases[min(i, len(phases) - 1)], + "title": titles[min(i, len(titles) - 1)], + "learning_goal": titles[min(i, len(titles) - 1)], + } + ) + return out + + +def _sample_path_steps() -> List[Dict[str, Any]]: + return [ + { + "index": 1, + "exercise_id": 101, + "title": "Mae Geri — Stand und Hüftöffnung", + "goal": "Frontkick mit geöffneter Hüfte aus Grundstellung", + "is_bridge": False, + "is_ai_proposal": False, + "reasons": ["Stufen-Gate: Grundlagen"], + }, + { + "index": 2, + "exercise_id": 102, + "title": "Mae Geri — Ballen und Rückzug", + "goal": "Präziser Ballentreffer mit kontrolliertem Rückzug", + "is_bridge": False, + "is_ai_proposal": False, + "reasons": ["Nachfolger im Graph"], + }, + ] + + +def _sample_planning_context() -> Dict[str, Any]: + return { + "scope": "progression_path", + "goal_query": "Mae Geri vom Grundschritt bis zur Kumite-Nähe", + "stage_index": 1, + "learning_goal": "Hüftöffnung und Ballen-Fokus", + } + + +def _sample_target_profile() -> Dict[str, Any]: + return { + "primary_focus": "Kihon", + "training_type": "Breitensport", + "skill_expectations": ["Geri Waza", "Koordination"], + } + + +def _sample_candidates() -> List[Dict[str, Any]]: + return [ + { + "exercise_id": 101, + "title": "Mae Geri — Stand und Hüftöffnung", + "summary": "Frontkick mit Hüftöffnung", + "skill_names": ["Geri Waza"], + "score_hint": 0.82, + }, + { + "exercise_id": 102, + "title": "Mae Geri — Ballen und Rückzug", + "summary": "Ballentreffer mit Rückzug", + "skill_names": ["Geri Waza", "Koordination"], + "score_hint": 0.76, + }, + ] + + +def _load_catalog_variables(cur) -> Dict[str, str]: + from planning_exercise_intent import ( + _load_compact_catalog, + _load_skills_catalog_compact, + ) + + return { + "skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)), + "focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")), + "training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")), + "style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")), + "target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")), + } + + +def resolve_planning_prompt_preview_variables( + cur, + slug: str, + body: PlanningPromptPreviewInput, +) -> Dict[str, str]: + """Mustache-Variablen für Planungs-Prompt-Vorschau im Admin.""" + s = (slug or "").strip().lower() + if s not in PLANNING_PROMPT_SLUGS: + raise ValueError(f"Kein Planungs-Prompt-Slug: {slug!r}") + + goal_query = (body.goal_query or "").strip() or "Mae Geri Progression" + search_query = (body.search_query or "").strip() or goal_query + max_steps = int(body.max_steps) + brief = build_semantic_brief(goal_query) + brief_json = _compact_json(brief_to_summary_dict(brief)) + goal_analysis = _sample_goal_analysis() + major_steps = _sample_major_steps(max_steps) + intent_ctx = build_planning_intent_context( + goal_query=goal_query, + goal_analysis=goal_analysis, + semantic_brief=brief, + extra_context=(body.user_notes or "").strip() or None, + ) + intent_ctx_json = _compact_json(intent_ctx.to_api_dict()) + ctx = _sample_planning_context() + target = _sample_target_profile() + catalogs = _load_catalog_variables(cur) + + if s == "planning_progression_start_target": + return { + "goal_query": goal_query, + "semantic_brief_json": brief_json, + "user_notes": (body.user_notes or "").strip(), + } + + if s == "planning_progression_goal_analysis": + return { + "goal_query": goal_query, + "semantic_brief_json": brief_json, + } + + if s == "planning_progression_roadmap": + return { + "goal_query": goal_query, + "goal_analysis_json": _compact_json(goal_analysis), + "semantic_brief_json": brief_json, + "max_steps": str(max_steps), + } + + if s == "planning_progression_stage_spec": + return { + "goal_query": goal_query, + "goal_analysis_json": _compact_json(goal_analysis), + "major_steps_json": _compact_json(major_steps), + "intent_context_json": intent_ctx_json, + "semantic_brief_json": brief_json, + } + + if s == "planning_exercise_query_semantics": + return { + "search_query": search_query, + "semantic_brief_json": brief_json, + } + + if s == "planning_exercise_path_qa": + return { + "goal_query": goal_query, + "semantic_brief_json": brief_json, + "steps_json": _compact_json(_sample_path_steps()), + "gaps_json": _compact_json([]), + "bridge_inserts_json": _compact_json([]), + } + + if s == "planning_exercise_search_intent": + return { + "search_query": search_query, + "heuristic_intent": "progression_next", + "scenario_hint": "preset_next", + "planning_context_json": _compact_json(ctx), + "target_profile_json": _compact_json(target), + **catalogs, + } + + if s == "planning_exercise_search_rank": + return { + "search_query": search_query, + "intent": "progression_next", + "planning_context_json": _compact_json(ctx), + "target_profile_json": _compact_json(target), + "candidates_json": _compact_json(_sample_candidates()), + "result_limit": "5", + } + + if s == "planning_exercise_expectation_profile": + return { + "heuristic_intent": "suggest_next", + "planning_context_json": _compact_json(ctx), + "target_profile_json": _compact_json(target), + **{k: v for k, v in catalogs.items() if k != "style_directions_catalog_json"}, + } + + raise ValueError(f"Planungs-Prompt-Slug nicht implementiert: {slug!r}") + + +__all__ = [ + "PLANNING_PROMPT_SLUGS", + "PlanningPromptPreviewInput", + "is_planning_prompt_slug", + "resolve_planning_prompt_preview_variables", +] diff --git a/backend/openrouter_chat.py b/backend/openrouter_chat.py index 8a7dd88..516b2dc 100644 --- a/backend/openrouter_chat.py +++ b/backend/openrouter_chat.py @@ -196,6 +196,13 @@ def openrouter_chat_completion( cc, ) + try: + from planning_llm_usage import record_planning_llm_call + + record_planning_llm_call(1) + except Exception: + pass + return joined diff --git a/backend/planning_llm_usage.py b/backend/planning_llm_usage.py new file mode 100644 index 0000000..567c3ba --- /dev/null +++ b/backend/planning_llm_usage.py @@ -0,0 +1,62 @@ +""" +Zähler für produktive OpenRouter-Aufrufe innerhalb einer Planungs-API-Anfrage. + +Wird per ContextVar gesetzt (Router: ``planning_llm_call_meter``); ``openrouter_chat_completion`` +erhöht den Zähler nach erfolgreicher Antwort — nur wenn ein Meter aktiv ist. +""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Iterator, Optional + +_llm_call_counter: ContextVar[Optional["PlanningLlmCallCounter"]] = ContextVar( + "planning_llm_call_counter", + default=None, +) + + +class PlanningLlmCallCounter: + """Anzahl erfolgreicher OpenRouter-Chat-Completions in einem Request-Kontext.""" + + __slots__ = ("count",) + + def __init__(self) -> None: + self.count = 0 + + def record(self, amount: int = 1) -> None: + try: + n = int(amount) + except (TypeError, ValueError): + n = 1 + if n > 0: + self.count += n + + +def current_planning_llm_call_counter() -> Optional[PlanningLlmCallCounter]: + return _llm_call_counter.get() + + +def record_planning_llm_call(amount: int = 1) -> None: + counter = _llm_call_counter.get() + if counter is not None: + counter.record(amount) + + +@contextmanager +def planning_llm_call_meter() -> Iterator[PlanningLlmCallCounter]: + """Aktiviert LLM-Zählung für den umschlossenen Block (inkl. verschachtelter Aufrufe).""" + counter = PlanningLlmCallCounter() + token = _llm_call_counter.set(counter) + try: + yield counter + finally: + _llm_call_counter.reset(token) + + +__all__ = [ + "PlanningLlmCallCounter", + "current_planning_llm_call_counter", + "planning_llm_call_meter", + "record_planning_llm_call", +] diff --git a/backend/routers/ai_prompts_admin.py b/backend/routers/ai_prompts_admin.py index 2a4e106..a051435 100644 --- a/backend/routers/ai_prompts_admin.py +++ b/backend/routers/ai_prompts_admin.py @@ -14,6 +14,11 @@ from auth import require_auth from club_tenancy import is_superadmin from ai_prompt_context import ExerciseFormAiPromptContext from ai_prompt_job import resolve_exercise_form_variables +from ai_prompt_planning_preview import ( + PlanningPromptPreviewInput, + is_planning_prompt_slug, + resolve_planning_prompt_preview_variables, +) from ai_prompt_runtime import render_ai_prompt_template_for_row from db import get_cursor, get_db, r2d from prompt_resolver import exercise_placeholder_catalog @@ -62,7 +67,12 @@ class AiPromptUpdateBody(BaseModel): class AiPromptPreviewBody(ExerciseFormAiPromptContext): - """Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint).""" + """Preview-POST: Übungs-KI und Planungs-Prompts.""" + + goal_query: Optional[str] = Field(default=None, max_length=2000) + user_notes: Optional[str] = Field(default=None, max_length=2000) + max_steps: Optional[int] = Field(default=None, ge=2, le=10) + search_query: Optional[str] = Field(default=None, max_length=2000) @router.get("/api/admin/ai-prompts/catalog/placeholders") @@ -223,6 +233,17 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict = vars_map = resolve_exercise_form_variables(cur, slug, body) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e + elif is_planning_prompt_slug(slug): + planning_in = PlanningPromptPreviewInput( + goal_query=(body.goal_query or "Mae Geri vom Grundschritt bis zur Kumite-Nähe").strip(), + user_notes=(body.user_notes or "").strip(), + max_steps=body.max_steps if body.max_steps is not None else 5, + search_query=(body.search_query or body.goal_query or "").strip() or None, + ) + try: + vars_map = resolve_planning_prompt_preview_variables(cur, slug, planning_in) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e elif slug == "pipeline": vars_map = {} warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau." diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index 8619cd6..1545b00 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -7,6 +7,7 @@ from db import get_db, get_cursor from tenant_context import TenantContext, get_tenant_context from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path +from planning_llm_usage import planning_llm_call_meter from account_lifecycle import assert_min_account_state from capabilities import probe_capability from club_features import ( @@ -46,19 +47,25 @@ def post_planning_exercise_suggest( ) with get_db() as conn: cur = get_cursor(conn) - result = suggest_planning_exercises(cur, tenant=tenant, body=body) - if uses_ai: + with planning_llm_call_meter() as llm_meter: + result = suggest_planning_exercises(cur, tenant=tenant, body=body) + if uses_ai and llm_meter.count > 0: usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=club_id, profile_id=tenant.profile_id, portal_role=tenant.global_role, action="planning_suggest", + amount=llm_meter.count, cur=cur, tenant=tenant, conn=conn, ) result = merge_feature_usage_into_response(result, usage) + if isinstance(result, dict): + result["llm_call_count"] = llm_meter.count + elif uses_ai and isinstance(result, dict): + result["llm_call_count"] = 0 return result @@ -97,17 +104,23 @@ def post_progression_path_suggest( ) with get_db() as conn: cur = get_cursor(conn) - result = suggest_progression_path(cur, tenant=tenant, body=body) - if uses_ai: + with planning_llm_call_meter() as llm_meter: + result = suggest_progression_path(cur, tenant=tenant, body=body) + if uses_ai and llm_meter.count > 0: usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=club_id, profile_id=tenant.profile_id, portal_role=tenant.global_role, action="progression_path_suggest", + amount=llm_meter.count, cur=cur, tenant=tenant, conn=conn, ) result = merge_feature_usage_into_response(result, usage) + if isinstance(result, dict): + result["llm_call_count"] = llm_meter.count + elif uses_ai and isinstance(result, dict): + result["llm_call_count"] = 0 return result diff --git a/backend/tests/test_ai_prompt_planning_preview.py b/backend/tests/test_ai_prompt_planning_preview.py new file mode 100644 index 0000000..d5c31c1 --- /dev/null +++ b/backend/tests/test_ai_prompt_planning_preview.py @@ -0,0 +1,89 @@ +"""Admin-Vorschau für Planungs-Prompt-Slugs.""" +from unittest.mock import MagicMock, patch + +import pytest + +from ai_prompt_planning_preview import ( + PLANNING_PROMPT_SLUGS, + PlanningPromptPreviewInput, + is_planning_prompt_slug, + resolve_planning_prompt_preview_variables, +) + + +def test_is_planning_prompt_slug(): + assert is_planning_prompt_slug("planning_progression_roadmap") + assert is_planning_prompt_slug("PLANNING_EXERCISE_PATH_QA") + assert not is_planning_prompt_slug("exercise_summary") + assert not is_planning_prompt_slug("") + + +def test_resolve_roadmap_preview_variables(): + body = PlanningPromptPreviewInput(goal_query="Mae Geri Basics", max_steps=4) + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + "planning_progression_roadmap", + body, + ) + assert vars_map["goal_query"] == "Mae Geri Basics" + assert vars_map["max_steps"] == "4" + assert "goal_analysis_json" in vars_map + assert "semantic_brief_json" in vars_map + + +def test_resolve_stage_spec_includes_intent_context(): + body = PlanningPromptPreviewInput(user_notes="Breitensport") + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + "planning_progression_stage_spec", + body, + ) + assert "intent_context_json" in vars_map + assert "major_steps_json" in vars_map + + +@patch("ai_prompt_planning_preview._load_catalog_variables") +def test_resolve_search_intent_includes_catalogs(mock_catalog): + mock_catalog.return_value = { + "skills_catalog_json": "[]", + "focus_areas_catalog_json": "[]", + "training_types_catalog_json": "[]", + "style_directions_catalog_json": "[]", + "target_groups_catalog_json": "[]", + } + body = PlanningPromptPreviewInput(search_query="Mae Geri nächster Schritt") + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + "planning_exercise_search_intent", + body, + ) + assert vars_map["search_query"] == "Mae Geri nächster Schritt" + assert vars_map["skills_catalog_json"] == "[]" + + +def test_non_planning_slug_raises(): + with pytest.raises(ValueError, match="Kein Planungs-Prompt-Slug"): + resolve_planning_prompt_preview_variables( + MagicMock(), + "exercise_summary", + PlanningPromptPreviewInput(), + ) + + +def test_all_registered_slugs_resolve(): + for slug in PLANNING_PROMPT_SLUGS: + with patch("ai_prompt_planning_preview._load_catalog_variables") as mock_catalog: + mock_catalog.return_value = { + "skills_catalog_json": "[]", + "focus_areas_catalog_json": "[]", + "training_types_catalog_json": "[]", + "style_directions_catalog_json": "[]", + "target_groups_catalog_json": "[]", + } + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + slug, + PlanningPromptPreviewInput(), + ) + assert isinstance(vars_map, dict) + assert len(vars_map) >= 1 diff --git a/backend/tests/test_planning_llm_usage.py b/backend/tests/test_planning_llm_usage.py new file mode 100644 index 0000000..03946ca --- /dev/null +++ b/backend/tests/test_planning_llm_usage.py @@ -0,0 +1,94 @@ +"""LLM-Zählung für Planungs-APIs (P1-C2).""" +from unittest.mock import MagicMock, patch + +import pytest + +from planning_llm_usage import ( + current_planning_llm_call_counter, + planning_llm_call_meter, + record_planning_llm_call, +) + + +def test_meter_inactive_by_default(): + assert current_planning_llm_call_counter() is None + record_planning_llm_call(3) + assert current_planning_llm_call_counter() is None + + +def test_meter_counts_within_scope(): + with planning_llm_call_meter() as meter: + record_planning_llm_call(1) + record_planning_llm_call(2) + assert meter.count == 3 + + +def test_openrouter_increments_active_meter(): + from openrouter_chat import openrouter_chat_completion + + fake_resp = MagicMock() + fake_resp.status_code = 200 + fake_resp.json.return_value = { + "choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}], + } + + with planning_llm_call_meter() as meter: + with patch("openrouter_chat.httpx.Client") as client_cls: + client = MagicMock() + client.__enter__.return_value = client + client.post.return_value = fake_resp + client_cls.return_value = client + out = openrouter_chat_completion( + api_key="test-key", + model="test/model", + user_content="hello", + ) + assert out == "ok" + assert meter.count == 1 + + +def test_openrouter_skips_meter_on_http_error(): + from openrouter_chat import OpenRouterError, openrouter_chat_completion + + fake_resp = MagicMock() + fake_resp.status_code = 500 + fake_resp.json.return_value = {"error": {"message": "fail"}} + fake_resp.text = "fail" + + with planning_llm_call_meter() as meter: + with patch("openrouter_chat.httpx.Client") as client_cls: + client = MagicMock() + client.__enter__.return_value = client + client.post.return_value = fake_resp + client_cls.return_value = client + with pytest.raises(OpenRouterError): + openrouter_chat_completion( + api_key="test-key", + model="test/model", + user_content="hello", + ) + assert meter.count == 0 + + +def test_uses_ai_gap_fill_not_counted_without_openrouter(): + """Regression: Gap-Fill-Flag allein löst keinen OpenRouter-Aufruf aus.""" + from planning_exercise_path_builder import ProgressionPathSuggestRequest + + body = ProgressionPathSuggestRequest( + query="Mae Geri Progression", + include_llm_intent=False, + include_llm_path_qa=False, + include_llm_roadmap=False, + include_llm_start_target=False, + include_ai_gap_fill=True, + evaluate_only=True, + evaluate_steps=[], + ) + uses_ai = ( + body.include_llm_intent + or body.include_llm_path_qa + or body.include_llm_roadmap + or body.include_llm_start_target + or (body.start_target_only and body.include_llm_start_target) + ) + assert uses_ai is False diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 4bb2b81..502c297 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -39,7 +39,6 @@ import { compareDiffsForDialog, dedupeGapOffersBySlot, draftHasLibrarySlotAssignments, - draftRetrievalBoostExerciseIds, EMPTY_PLANNING_CATALOG_CONTEXT, filterGapOffersForUnfilledSlots, hydrateProgressionGraphDraft, @@ -478,28 +477,6 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } } - const buildMatchRequestBase = (synced) => { - const override = majorStepsToOverridePayload(synced.slots) - return { - query: (synced.goalQuery || '').trim(), - max_steps: synced.slots.length, - include_llm_intent: true, - include_path_qa: true, - include_llm_path_qa: true, - include_path_reorder: false, - include_ai_gap_fill: true, - include_roadmap_preview: true, - include_llm_roadmap: false, - roadmap_first: true, - roadmap_override: override, - slot_assignments: slotsToSlotAssignments(synced), - retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced), - progression_graph_id: Number(graphId), - ...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes), - ...catalogApiPayload, - } - } - const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => { setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…') const baselineRes = await fetchPathEvaluate(synced) diff --git a/frontend/src/pages/AdminAiPromptsPage.jsx b/frontend/src/pages/AdminAiPromptsPage.jsx index 768a314..5a4779b 100644 --- a/frontend/src/pages/AdminAiPromptsPage.jsx +++ b/frontend/src/pages/AdminAiPromptsPage.jsx @@ -31,8 +31,22 @@ export default function AdminAiPromptsPage() { const [pvExec, setPvExec] = useState('

Ablauf hier

') const [pvHint, setPvHint] = useState('') const [pvFocusId, setPvFocusId] = useState('') + const [pvGoalQuery, setPvGoalQuery] = useState( + 'Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe' + ) + const [pvUserNotes, setPvUserNotes] = useState('Fokus Breitensport, ohne Wettkampfdruck.') + const [pvMaxSteps, setPvMaxSteps] = useState('5') + const [pvSearchQuery, setPvSearchQuery] = useState('') const [pvPreview, setPvPreview] = useState(null) + const selectedSlug = (detail?.slug || '').trim().toLowerCase() + const isExercisePreviewSlug = [ + 'exercise_summary', + 'exercise_skill_suggestions', + 'exercise_instruction_rewrite', + ].includes(selectedSlug) + const isPlanningPreviewSlug = selectedSlug.startsWith('planning_') + const loadList = useCallback(async () => { const [pList, cat] = await Promise.all([ api.listAdminAiPrompts(), @@ -133,15 +147,23 @@ export default function AdminAiPromptsPage() { if (!detail?.id) return setError('') try { - const body = { - title: pvTitle, - goal: pvGoal, - execution: pvExec, - focus_hint: pvHint || undefined, - } - const fid = parseInt(String(pvFocusId).trim(), 10) - if (Number.isFinite(fid) && fid >= 1) { - body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }] + const body = {} + if (isPlanningPreviewSlug) { + body.goal_query = pvGoalQuery.trim() || undefined + body.user_notes = pvUserNotes.trim() || undefined + const ms = parseInt(String(pvMaxSteps).trim(), 10) + if (Number.isFinite(ms) && ms >= 2 && ms <= 10) body.max_steps = ms + const sq = pvSearchQuery.trim() + if (sq) body.search_query = sq + } else if (isExercisePreviewSlug) { + body.title = pvTitle + body.goal = pvGoal + body.execution = pvExec + body.focus_hint = pvHint || undefined + const fid = parseInt(String(pvFocusId).trim(), 10) + if (Number.isFinite(fid) && fid >= 1) { + body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }] + } } const r = await api.previewAdminAiPrompt(detail.id, body) setPvPreview(r) @@ -171,8 +193,8 @@ export default function AdminAiPromptsPage() {

KI Prompts

- Datenbankvorlagen (ai_prompts) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig - aufgelöst — die Vorschau unten ruft kein externes Modell auf. + Datenbankvorlagen (ai_prompts) für Übungs- und Planungs-KI. Platzhalter im Mustache-Stil werden + serverseitig aufgelöst — die Vorschau unten ruft kein externes Modell auf.

{error ?

{error}

: null} @@ -301,33 +323,89 @@ export default function AdminAiPromptsPage() {

Vorschau (ohne OpenRouter)

-
-
- - setPvTitle(e.target.value)} /> -
-
- - setPvFocusId(e.target.value)} - /> -
-
-
- - setPvHint(e.target.value)} /> -
-
- -