Refactor AI Prompt System and Enhance Functionality
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s

- Introduced `load_and_render_ai_prompt` and `render_ai_prompt_template_for_row` in `ai_prompt_runtime` to streamline prompt loading and rendering processes.
- Added `AiPromptUnavailableError` for better error handling when prompts are inactive or missing.
- Created `ai_prompt_job` module with `ExerciseFormAiPromptContext` and `resolve_exercise_form_variables` to support admin preview functionality.
- Updated documentation and target architecture to reflect changes in the AI prompt system.
- Incremented application version to 0.8.160 and updated changelog accordingly.
This commit is contained in:
Lars 2026-05-22 12:19:52 +02:00
parent e22266a18c
commit 0551bb3d80
7 changed files with 146 additions and 49 deletions

View File

@ -26,7 +26,7 @@ Alle produktiven KI-Aufrufe sollten mittelfristig über eine **einheitliche Fass
Router und Frontend rufen diese Schicht oder schmale Orchestratoren — **nicht** direkt `httpx`/OpenRouter an jeder Ecke verteilt.
**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` mit **Kontext-Arten** und **gemeinsamen DB-Ladeschritten** für `ai_prompts`; Übungs-KI konsumiert diese Schicht ohne Zirkelschluss zu Domänlogik (`exercise_ai`).
**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` (`load_ai_prompt_row`, `load_and_render_ai_prompt`, Kontext-Arten) sowie `backend/ai_prompt_job.py` (Pydantic `ExerciseFormAiPromptContext` fuer Uebungs-Prompts — Admin-Vorschau + erweiterbare Router-Nutzung); `exercise_ai` orchestriert OpenRouter nach dem Rendern.
### 2.2 Trennung: Semantik vs. Transport
@ -122,22 +122,24 @@ Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter,
```mermaid
flowchart LR
subgraph heute
subgraph laufzeit
A[ai_prompts DB]
B[prompt_resolver Mustache]
C[ai_prompt_runtime Loader + ContextKind]
D[exercise_ai]
C[ai_prompt_runtime]
J[ai_prompt_job Pydantic]
D[exercise_ai OpenRouter]
end
A --> B
A --> C
C --> B
J --> D
C --> D
B --> D
```
| Phase | Inhalt |
|-------|--------|
| **P0 (gestartet)** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI nutzt Laufzeit; Platzhalter-Katalog pro Kontext erweiterbar. |
| **P1** | Einheitliche `run_ai_job`-Fassade (Slug + Kind + Pydantic-Payload + Validierung); Router nur noch dünne Adapter. |
| **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. |
| **P1 (teilweise)** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **Pydantic** `ExerciseFormAiPromptContext` / `resolve_exercise_form_variables` in `ai_prompt_job` (Admin-Vorschau + gemeinsame Schnittstelle). Nächster Schritt: `POST /exercises/ai/suggest` kann dasselbe Kontextmodell nutzen; optionale `execute_*`-Fassade fuer OpenRouter-Schritt. |
| **P2** | Versionierung oder Audit-Spalten; optionale Modell-/Temperatur-Overrides pro Slug in DB oder Config-Tabelle. |
| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |
@ -157,7 +159,7 @@ flowchart LR
- Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md`
- Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md`
- Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`
- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`
- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`, `backend/ai_prompt_job.py`
---

58
backend/ai_prompt_job.py Normal file
View File

@ -0,0 +1,58 @@
"""
KI-Prompt Jobs: validierter Kontext (Pydantic) + Zugriff auf gemeinsame Resolver.
Importiert exercise_ai nur fuer Platzhalter-Builder Router importieren dieses Modul oder exercise_ai,
nicht umgekehrt von exercise_ai nach ai_prompt_job (Zirkel vermeiden).
"""
from __future__ import annotations
from typing import Dict, List, Optional, Tuple
from pydantic import BaseModel, Field
from exercise_ai import build_exercise_placeholder_variables
class ExerciseFormAiFocusRow(BaseModel):
"""Fokusbereich fuer Skill-Retrieval-Kontext (wie ExerciseAiFocusCtx / Admin-Preview)."""
focus_area_id: int = Field(..., ge=1)
is_primary: Optional[bool] = False
class ExerciseFormAiPromptContext(BaseModel):
"""
Eingabe fuer Uebungsbezogene Prompts (Kurzfassung / Skill-JSON-Vorschlag).
Entspricht fachlich dem Preview-Body unter /api/admin/ai-prompts/*/preview.
"""
title: Optional[str] = ""
goal: Optional[str] = None
execution: Optional[str] = None
focus_hint: Optional[str] = None
focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None
def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]:
if not self.focus_areas_context:
return None
return [(int(x.focus_area_id), bool(x.is_primary)) for x in self.focus_areas_context]
def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptContext) -> Dict[str, str]:
"""Baut die Mustache-Map fuer exercise_summary / exercise_skill_suggestions."""
return build_exercise_placeholder_variables(
cur,
slug=slug,
title=(ctx.title or "").strip(),
goal=ctx.goal,
execution=ctx.execution,
focus_area_hint=ctx.focus_hint,
focus_areas_context=ctx.focus_area_tuples(),
)
__all__ = [
"ExerciseFormAiFocusRow",
"ExerciseFormAiPromptContext",
"resolve_exercise_form_variables",
]

View File

@ -7,7 +7,9 @@ load_ai_prompt_row und die Enum; Platzhalter bauen sie selbst oder über geteilt
from __future__ import annotations
from enum import Enum
from typing import Any, Dict, Optional
from typing import Any, Dict, Mapping, Optional, Tuple
from prompt_resolver import MustacheRenderResult, render_mustache_template
_EXERCISE_AI_SLUGS = frozenset(
{
@ -67,8 +69,45 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
return d
class AiPromptUnavailableError(LookupError):
"""Kein aktiver Prompt fuer slug (oder Zeile fehlt)."""
def __init__(self, slug: str) -> None:
self.slug = (slug or "").strip()
super().__init__(self.slug)
def render_ai_prompt_template_for_row(
row: Mapping[str, Any],
variables: Mapping[str, str],
) -> MustacheRenderResult:
"""Ersetzt Platzhalter anhand einer bereits geladenen ai_prompts-Zeile (z. B. Admin-Vorschauch, inkl. inaktiv)."""
return render_mustache_template(str(row.get("template") or ""), variables)
def load_and_render_ai_prompt(
cur,
slug: str,
variables: Mapping[str, str],
*,
active_only: bool = True,
) -> Tuple[Dict[str, Any], MustacheRenderResult]:
"""
Laedt einen aktiven Prompt und wendet Mustache-Variablen an.
Wirft AiPromptUnavailableError, wenn die Zeile fehlt oder (bei active_only) inaktiv ist.
"""
row = load_ai_prompt_row(cur, slug, active_only=active_only)
if not row:
raise AiPromptUnavailableError(slug)
rr = render_ai_prompt_template_for_row(row, variables)
return dict(row), rr
__all__ = [
"AiPromptContextKind",
"AiPromptUnavailableError",
"context_kind_for_slug",
"load_ai_prompt_row",
"load_and_render_ai_prompt",
"render_ai_prompt_template_for_row",
]

View File

@ -18,8 +18,7 @@ from fastapi import HTTPException
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
from ai_prompt_runtime import load_ai_prompt_row
from prompt_resolver import render_mustache_template
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
_LOGGER = logging.getLogger("shinkan.exercise_ai")
@ -715,9 +714,6 @@ def run_exercise_ai_suggestion(
)
if want_summary:
prow = load_ai_prompt_row(cur, "exercise_summary")
if not prow:
raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
try:
ctx = build_exercise_placeholder_variables(
cur,
@ -730,7 +726,13 @@ def run_exercise_ai_suggestion(
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
rendered = render_mustache_template(str(prow["template"]), ctx)
try:
prow, rendered = load_and_render_ai_prompt(cur, "exercise_summary", ctx)
except AiPromptUnavailableError:
raise HTTPException(
status_code=503,
detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.",
) from None
prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
@ -755,12 +757,6 @@ def run_exercise_ai_suggestion(
result["summary"] = {"text": text, "ai_generated": True, "model": model}
if want_skills:
srow = load_ai_prompt_row(cur, "exercise_skill_suggestions")
if not srow:
raise HTTPException(
status_code=503,
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
)
try:
ctx = build_exercise_placeholder_variables(
cur,
@ -773,7 +769,13 @@ def run_exercise_ai_suggestion(
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
rendered = render_mustache_template(str(srow["template"]), ctx)
try:
srow, rendered = load_and_render_ai_prompt(cur, "exercise_skill_suggestions", ctx)
except AiPromptUnavailableError:
raise HTTPException(
status_code=503,
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
) from None
prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(

View File

@ -12,9 +12,10 @@ from pydantic import BaseModel, Field
from auth import require_auth
from club_tenancy import is_superadmin
from ai_prompt_job import ExerciseFormAiPromptContext, resolve_exercise_form_variables
from ai_prompt_runtime import render_ai_prompt_template_for_row
from db import get_cursor, get_db, r2d
from exercise_ai import build_exercise_placeholder_variables
from prompt_resolver import exercise_placeholder_catalog, render_mustache_template
from prompt_resolver import exercise_placeholder_catalog
router = APIRouter(tags=["admin_ai_prompts"])
@ -211,28 +212,13 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
row = _fetch_prompt_any(cur, prompt_id)
slug = (row.get("slug") or "").strip().lower()
tpl_raw = row.get("template") or ""
fctx_list: Optional[List[tuple[int, bool]]] = None
if body.focus_areas_context:
pairs: List[tuple[int, bool]] = []
for x in body.focus_areas_context:
pairs.append((int(x.focus_area_id), bool(x.is_primary)))
fctx_list = pairs
vars_map: Dict[str, str]
warn: Optional[str] = None
if slug in ("exercise_summary", "exercise_skill_suggestions"):
try:
vars_map = build_exercise_placeholder_variables(
cur,
slug=slug,
title=(body.title or "").strip(),
goal=body.goal,
execution=body.execution,
focus_area_hint=body.focus_hint,
focus_areas_context=fctx_list,
)
pf_ctx = ExerciseFormAiPromptContext.model_validate(body.model_dump())
vars_map = resolve_exercise_form_variables(cur, slug, pf_ctx)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
elif slug == "pipeline":
@ -242,7 +228,7 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
vars_map = {}
warn = f"Slug {slug!r}: noch kein Vorschau-Kontext definiert — Roh-Template ohne Ersetzung."
rendered = render_mustache_template(str(tpl_raw), vars_map)
rendered = render_ai_prompt_template_for_row(row, vars_map)
return {
"slug": slug,
"resolved_template": rendered.text,

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.159"
APP_VERSION = "0.8.160"
BUILD_DATE = "2026-05-30"
DB_SCHEMA_VERSION = "20260530069"
@ -19,13 +19,14 @@ MODULE_VERSIONS = {
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
"admin_ai_prompts": "1.0.1", # Prompt-Pflege + Zielarchitektur-Doku; gemeinsamer DB-Load uber ai_prompt_runtime
"ai_prompt_runtime": "0.1.0", # AiPromptContextKind, load_ai_prompt_row — Erweiterung Planung ohne Zirkel zu exercise_ai
"admin_ai_prompts": "1.0.2", # Preview: ai_prompt_job + render_ai_prompt_template_for_row
"ai_prompt_job": "0.1.0", # ExerciseFormAiPromptContext, resolve_exercise_form_variables — P1 Kontext-Schnittstelle
"ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row
"groups": "0.1.0",
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.31.1", # AI nutzt load_ai_prompt_row aus ai_prompt_runtime
"exercises": "2.31.2", # exercise_ai: load_and_render_ai_prompt statt getrennt load+render
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -40,6 +41,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.160",
"date": "2026-05-30",
"changes": [
"KI Prompt P1: ai_prompt_runtime load_and_render_ai_prompt + render_ai_prompt_template_for_row; AiPromptUnavailableError;",
"Neu ai_prompt_job: ExerciseFormAiPromptContext (Pydantic), resolve_exercise_form_variables; Admin-Prompt-Vorschau nutzt gleichen Pfad wie exercise_ai-Logik;",
"Zielarchitektur-Doku: Phasendiagramm P0/P1 angepasst.",
],
},
{
"version": "0.8.159",
"date": "2026-05-30",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-30
**App-Version / DB-Schema:** App **`0.8.159`** u.a. **KI-Prompt-Zielarchitektur** + gemeinsames Modul **`ai_prompt_runtime`**; DB-Schema **`backend/version.py`** → `APP_VERSION`, `DB_SCHEMA_VERSION` (aktuell `20260530069`).
**App-Version / DB-Schema:** App **`0.8.160`** (u.a. KI Prompt P1: `load_and_render_ai_prompt`, `ai_prompt_job`); Zielarchitektur-Doku weiterhin **`.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md`**. DB: maßgeblich **`backend/version.py`** → `APP_VERSION`, `DB_SCHEMA_VERSION` (aktuell `20260530069`).
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -89,11 +89,11 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.159**)
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.160**)
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0P4.
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
- **Runtime:** **`backend/ai_prompt_runtime.py`** — `AiPromptContextKind`, `load_ai_prompt_row` (gemeinsamer DB-Lesezugriff, kein Import von `exercise_ai`); **`exercise_ai`** nutzt `load_ai_prompt_row` fuer aktive Prompts
- **Runtime / Job:** **`ai_prompt_runtime`** (`load_and_render_ai_prompt`, `AiPromptUnavailableError`, Rendern aus DB-Zeile fuer Vorschau); **`ai_prompt_job`** — Pydantic **`ExerciseFormAiPromptContext`**, **`resolve_exercise_form_variables`** (Admin-Preview + Schnittstelle fuer weitere Router); **`exercise_ai`** laedt/rendert ueber Laufzeit und ruft OpenRouter
- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`069`** setzt **`default_template`** wo leer; Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
- **`exercise_ai`:** Gewichtungen, KategorieAnteilCaps (~Token), Keyword-Patches aus Ziel/Durchführung (z.B. Rollenspiel vs. Befreiung/Haltegriff)
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas`**Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`), **`/api/admin/ai-prompts*`** (`routers/ai_prompts_admin.py`), UI **`/admin/ai-prompts`**