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,
|
||||
)
|
||||
|
||||
try:
|
||||
from planning_llm_usage import record_planning_llm_call
|
||||
|
||||
record_planning_llm_call(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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 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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -31,8 +31,22 @@ export default function AdminAiPromptsPage() {
|
|||
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
|
||||
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() {
|
|||
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
|
||||
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig
|
||||
aufgelöst — die Vorschau unten ruft kein externes Modell auf.
|
||||
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs- und Planungs-KI. Platzhalter im Mustache-Stil werden
|
||||
serverseitig aufgelöst — die Vorschau unten ruft kein externes Modell auf.
|
||||
</p>
|
||||
{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)' }}>
|
||||
<h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4>
|
||||
<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>
|
||||
{isPlanningPreviewSlug ? (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 0 }}>
|
||||
Beispielkontext für Planungs-Prompts — echte Katalog-Auszüge aus der Datenbank, übrige Felder
|
||||
sind repräsentative Demo-Daten.
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Zielanfrage (goal_query)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={pvGoalQuery}
|
||||
onChange={(e) => setPvGoalQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Trainer-Notizen (user_notes)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={pvUserNotes}
|
||||
onChange={(e) => setPvUserNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="form-row">
|
||||
<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()}>
|
||||
Platzhalter auflösen
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user