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
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:
parent
0b203489f7
commit
9cee862c32
272
backend/ai_prompt_planning_preview.py
Normal file
272
backend/ai_prompt_planning_preview.py
Normal 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",
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
62
backend/planning_llm_usage.py
Normal file
62
backend/planning_llm_usage.py
Normal 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",
|
||||||
|
]
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
89
backend/tests/test_ai_prompt_planning_preview.py
Normal file
89
backend/tests/test_ai_prompt_planning_preview.py
Normal 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
|
||||||
94
backend/tests/test_planning_llm_usage.py
Normal file
94
backend/tests/test_planning_llm_usage.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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, Retrieval‑Raster)</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, Retrieval‑Raster)</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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user