Implement Planning Prompt Enhancements and LLM Usage Tracking
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 49s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m26s

- 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.
This commit is contained in:
Lars 2026-06-15 07:50:49 +02:00
parent 0b203489f7
commit 9cee862c32
9 changed files with 679 additions and 66 deletions

View File

@ -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",
]

View File

@ -196,6 +196,13 @@ def openrouter_chat_completion(
cc, cc,
) )
try:
from planning_llm_usage import record_planning_llm_call
record_planning_llm_call(1)
except Exception:
pass
return joined return joined

View File

@ -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",
]

View File

@ -14,6 +14,11 @@ from auth import require_auth
from club_tenancy import is_superadmin from club_tenancy import is_superadmin
from ai_prompt_context import ExerciseFormAiPromptContext from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_job import resolve_exercise_form_variables 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 ai_prompt_runtime import render_ai_prompt_template_for_row
from db import get_cursor, get_db, r2d from db import get_cursor, get_db, r2d
from prompt_resolver import exercise_placeholder_catalog from prompt_resolver import exercise_placeholder_catalog
@ -62,7 +67,12 @@ class AiPromptUpdateBody(BaseModel):
class AiPromptPreviewBody(ExerciseFormAiPromptContext): 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") @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) vars_map = resolve_exercise_form_variables(cur, slug, body)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from 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": elif slug == "pipeline":
vars_map = {} vars_map = {}
warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau." warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau."

View File

@ -7,6 +7,7 @@ from db import get_db, get_cursor
from tenant_context import TenantContext, get_tenant_context from tenant_context import TenantContext, get_tenant_context
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path 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 account_lifecycle import assert_min_account_state
from capabilities import probe_capability from capabilities import probe_capability
from club_features import ( from club_features import (
@ -46,19 +47,25 @@ def post_planning_exercise_suggest(
) )
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
result = suggest_planning_exercises(cur, tenant=tenant, body=body) with planning_llm_call_meter() as llm_meter:
if uses_ai: result = suggest_planning_exercises(cur, tenant=tenant, body=body)
if uses_ai and llm_meter.count > 0:
usage = consume_club_feature_with_usage( usage = consume_club_feature_with_usage(
feature_id="ai_calls", feature_id="ai_calls",
club_id=club_id, club_id=club_id,
profile_id=tenant.profile_id, profile_id=tenant.profile_id,
portal_role=tenant.global_role, portal_role=tenant.global_role,
action="planning_suggest", action="planning_suggest",
amount=llm_meter.count,
cur=cur, cur=cur,
tenant=tenant, tenant=tenant,
conn=conn, conn=conn,
) )
result = merge_feature_usage_into_response(result, usage) 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 return result
@ -97,17 +104,23 @@ def post_progression_path_suggest(
) )
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
result = suggest_progression_path(cur, tenant=tenant, body=body) with planning_llm_call_meter() as llm_meter:
if uses_ai: result = suggest_progression_path(cur, tenant=tenant, body=body)
if uses_ai and llm_meter.count > 0:
usage = consume_club_feature_with_usage( usage = consume_club_feature_with_usage(
feature_id="ai_calls", feature_id="ai_calls",
club_id=club_id, club_id=club_id,
profile_id=tenant.profile_id, profile_id=tenant.profile_id,
portal_role=tenant.global_role, portal_role=tenant.global_role,
action="progression_path_suggest", action="progression_path_suggest",
amount=llm_meter.count,
cur=cur, cur=cur,
tenant=tenant, tenant=tenant,
conn=conn, conn=conn,
) )
result = merge_feature_usage_into_response(result, usage) 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 return result

View File

@ -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

View File

@ -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

View File

@ -39,7 +39,6 @@ import {
compareDiffsForDialog, compareDiffsForDialog,
dedupeGapOffersBySlot, dedupeGapOffersBySlot,
draftHasLibrarySlotAssignments, draftHasLibrarySlotAssignments,
draftRetrievalBoostExerciseIds,
EMPTY_PLANNING_CATALOG_CONTEXT, EMPTY_PLANNING_CATALOG_CONTEXT,
filterGapOffersForUnfilledSlots, filterGapOffersForUnfilledSlots,
hydrateProgressionGraphDraft, 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' } = {}) => { const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…') setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…')
const baselineRes = await fetchPathEvaluate(synced) const baselineRes = await fetchPathEvaluate(synced)

View File

@ -31,8 +31,22 @@ export default function AdminAiPromptsPage() {
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>') const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
const [pvHint, setPvHint] = useState('') const [pvHint, setPvHint] = useState('')
const [pvFocusId, setPvFocusId] = 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 [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 loadList = useCallback(async () => {
const [pList, cat] = await Promise.all([ const [pList, cat] = await Promise.all([
api.listAdminAiPrompts(), api.listAdminAiPrompts(),
@ -133,15 +147,23 @@ export default function AdminAiPromptsPage() {
if (!detail?.id) return if (!detail?.id) return
setError('') setError('')
try { try {
const body = { const body = {}
title: pvTitle, if (isPlanningPreviewSlug) {
goal: pvGoal, body.goal_query = pvGoalQuery.trim() || undefined
execution: pvExec, body.user_notes = pvUserNotes.trim() || undefined
focus_hint: pvHint || undefined, const ms = parseInt(String(pvMaxSteps).trim(), 10)
} if (Number.isFinite(ms) && ms >= 2 && ms <= 10) body.max_steps = ms
const fid = parseInt(String(pvFocusId).trim(), 10) const sq = pvSearchQuery.trim()
if (Number.isFinite(fid) && fid >= 1) { if (sq) body.search_query = sq
body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }] } 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) const r = await api.previewAdminAiPrompt(detail.id, body)
setPvPreview(r) setPvPreview(r)
@ -171,8 +193,8 @@ export default function AdminAiPromptsPage() {
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1> <h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
</div> </div>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}> <p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig Datenbankvorlagen (<code>ai_prompts</code>) für Übungs- und Planungs-KI. Platzhalter im Mustache-Stil werden
aufgelöst die Vorschau unten ruft kein externes Modell auf. serverseitig aufgelöst die Vorschau unten ruft kein externes Modell auf.
</p> </p>
{error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null} {error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null}
@ -301,33 +323,89 @@ export default function AdminAiPromptsPage() {
<section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}> <section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4> <h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> {isPlanningPreviewSlug ? (
<div className="form-row"> <>
<label className="form-label">Titel</label> <p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 0 }}>
<input className="form-input" value={pvTitle} onChange={(e) => setPvTitle(e.target.value)} /> Beispielkontext für Planungs-Prompts echte Katalog-Auszüge aus der Datenbank, übrige Felder
</div> sind repräsentative Demo-Daten.
<div className="form-row"> </p>
<label className="form-label">Fokus-ID (optional, RetrievalRaster)</label> <div className="form-row">
<input <label className="form-label">Zielanfrage (goal_query)</label>
className="form-input" <textarea
placeholder="numerisch" className="form-input"
value={pvFocusId} rows={3}
onChange={(e) => setPvFocusId(e.target.value)} value={pvGoalQuery}
/> onChange={(e) => setPvGoalQuery(e.target.value)}
</div> />
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Fokus-Hinweistext</label> <label className="form-label">Trainer-Notizen (user_notes)</label>
<input className="form-input" value={pvHint} onChange={(e) => setPvHint(e.target.value)} /> <textarea
</div> className="form-input"
<div className="form-row"> rows={2}
<label className="form-label">Ziel (HTML möglich)</label> value={pvUserNotes}
<textarea className="form-input" rows={4} value={pvGoal} onChange={(e) => setPvGoal(e.target.value)} /> onChange={(e) => setPvUserNotes(e.target.value)}
</div> />
<div className="form-row"> </div>
<label className="form-label">Durchführung (HTML möglich)</label> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} /> <div className="form-row">
</div> <label className="form-label">max_steps (Roadmap)</label>
<input
className="form-input"
type="number"
min={2}
max={10}
value={pvMaxSteps}
onChange={(e) => setPvMaxSteps(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Suchanfrage (optional)</label>
<input
className="form-input"
placeholder="Leer = goal_query"
value={pvSearchQuery}
onChange={(e) => setPvSearchQuery(e.target.value)}
/>
</div>
</div>
</>
) : isExercisePreviewSlug ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-row">
<label className="form-label">Titel</label>
<input className="form-input" value={pvTitle} onChange={(e) => setPvTitle(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Fokus-ID (optional, RetrievalRaster)</label>
<input
className="form-input"
placeholder="numerisch"
value={pvFocusId}
onChange={(e) => setPvFocusId(e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Fokus-Hinweistext</label>
<input className="form-input" value={pvHint} onChange={(e) => setPvHint(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Ziel (HTML möglich)</label>
<textarea className="form-input" rows={4} value={pvGoal} onChange={(e) => setPvGoal(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Durchführung (HTML möglich)</label>
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} />
</div>
</>
) : (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
Für diesen Slug ist noch kein Beispielkontext hinterlegt es wird nur das Roh-Template ohne
Ersetzung angezeigt.
</p>
)}
<button type="button" className="btn btn-secondary" onClick={() => runPreview()}> <button type="button" className="btn btn-secondary" onClick={() => runPreview()}>
Platzhalter auflösen Platzhalter auflösen
</button> </button>