# KI-Prompt-System – Universelle Admin-Konfiguration

**Version:** 1.1
**Datum:** 2026-05-30
**Status:** Kern umgesetzt (`ai_prompts`, `prompt_resolver`, Superadmin-HTTP-API); Kaskaden geplant (Abschnitt 8)

**Zielbild (Roadmap):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Planung/Rahmen, Phasenplan.

**Ist-Stand API (Superadmin):**
- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
- Spalte **`openrouter_model`** (Migration **070**): Optional pro Prompt-Zeile; OpenRouter **`model`**-Parameter; **`NULL`/leer ⇒ `OPENROUTER_MODEL`** aus der Umgebung.

**Autor:** Claude Code
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System

**Verwandt (Skill-Katalog in Übungs-KI):** `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md` — Tabelle **`ai_skill_retrieval_profiles`** (`config`-JSON ergänzt inhaltliche Prompt-/Katalog-Steuerung neben Platzhaltern).

---

## 1. Konzept

### 1.1 Ziel

Alle KI-Aufrufe in Shinkan sind durch **admin-konfigurierbare Prompt-Templates**
steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet.

**Admins können:**
- Prompt-Texte anpassen (ohne Code-Änderung)
- Neue Prompt-Typen hinzufügen
- Prompts aktivieren/deaktivieren
- Prompts testen (Preview mit aufgelösten Platzhaltern)
- Platzhalter-Katalog einsehen

### 1.2 Anwendungsfälle in Shinkan

| Prompt-Slug | Verwendung |
|-------------|-----------|
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
| `exercise_instruction_rewrite` | Überarbeitet Anleitung: goal, execution, preparation, trainer_notes (JSON, prägnantes HTML) |
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |
| `wiki_import_cleanup` | Bereinigt importierten Wikitext in lesbares Deutsch |

### 1.3 Architektur (aus Mitai übernommen, vereinfacht)

```
Admin konfiguriert Prompt-Template in DB (ai_prompts)
         │
         ▼
KI-Aufruf: Backend lädt Template, löst {{Platzhalter}} auf
         │
         ▼
OpenRouter API → Antwort
         │
         ▼
Antwort wird dem Trainer als Vorschlag angeboten (nicht blind gespeichert)
```

---

## 2. Datenbank

### 2.1 Migration (020_ai_prompts.sql)

```sql
-- Migration 020: AI Prompt System (admin-konfigurierbar)
-- Basis: Mitai Jinkendo prompts system (vereinfacht für Shinkan MVP)
-- Autor: Claude Code

DO $$
BEGIN

-- ============================================================================
-- AI PROMPTS (Admin-verwaltete Prompt-Templates)
-- ============================================================================

CREATE TABLE IF NOT EXISTS ai_prompts (
    id SERIAL PRIMARY KEY,
    slug VARCHAR(100) NOT NULL UNIQUE,          -- z.B. 'exercise_summary'
    display_name VARCHAR(200) NOT NULL,
    description TEXT,

    -- Template mit {{Platzhalter}}-Syntax
    template TEXT NOT NULL,

    -- Kategorie: welcher Bereich der App nutzt diesen Prompt?
    category VARCHAR(50) DEFAULT 'exercise'
        CHECK (category IN ('exercise', 'training', 'matrix', 'import', 'admin')),

    -- Output-Format: was gibt die KI zurück?
    output_format VARCHAR(10) DEFAULT 'text'
        CHECK (output_format IN ('text', 'json')),

    -- JSON Schema für Validierung des KI-Outputs (nur wenn output_format='json')
    output_schema JSONB,

    -- System-Default: kann auf Original zurückgesetzt werden
    is_system_default BOOLEAN DEFAULT false,
    default_template TEXT,         -- Backup des originalen Templates

    -- Admin-Controls
    active BOOLEAN DEFAULT true,
    sort_order INT DEFAULT 0,

    -- Meta
    created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_ai_prompts_slug     ON ai_prompts(slug);
CREATE INDEX IF NOT EXISTS idx_ai_prompts_category ON ai_prompts(category);
CREATE INDEX IF NOT EXISTS idx_ai_prompts_active   ON ai_prompts(active, sort_order);

-- ============================================================================
-- TRIGGER
-- ============================================================================

DROP TRIGGER IF EXISTS ai_prompts_update ON ai_prompts;
CREATE TRIGGER ai_prompts_update
BEFORE UPDATE ON ai_prompts
FOR EACH ROW EXECUTE FUNCTION update_timestamp();

-- ============================================================================
-- SYSTEM-DEFAULT PROMPTS
-- ============================================================================

INSERT INTO ai_prompts (slug, display_name, description, template, category, output_format, is_system_default, default_template, sort_order) VALUES

-- 1. Exercise Summary
('exercise_summary',
 'Übungs-Zusammenfassung',
 'Generiert eine kurze Zusammenfassung (2-3 Sätze) einer Übung für Listen und Trainingspläne.',
$$Du bist Assistent für einen Kampfsport-Trainer.
Erstelle eine prägnante Zusammenfassung dieser Übung für die Anzeige in Listen und Trainingsplänen.
Die Zusammenfassung soll:
- 2-3 Sätze lang sein (maximal 200 Zeichen)
- Das Wesentliche: Was trainiert die Übung? Wie läuft sie ab?
- Sachlich und klar (keine Werbebotschaften)
- Auf Deutsch

Übung: {{exercise_title}}
Fokusbereich: {{exercise_focus_area}}
Ziel: {{exercise_goal}}
Durchführung: {{exercise_execution}}

Antworte NUR mit dem Zusammenfassungstext, ohne Anführungszeichen.$$,
 'exercise', 'text', true,
$$Du bist Assistent für einen Kampfsport-Trainer.
Erstelle eine prägnante Zusammenfassung dieser Übung für die Anzeige in Listen und Trainingsplänen.
Die Zusammenfassung soll:
- 2-3 Sätze lang sein (maximal 200 Zeichen)
- Das Wesentliche: Was trainiert die Übung? Wie läuft sie ab?
- Sachlich und klar (keine Werbebotschaften)
- Auf Deutsch

Übung: {{exercise_title}}
Fokusbereich: {{exercise_focus_area}}
Ziel: {{exercise_goal}}
Durchführung: {{exercise_execution}}

Antworte NUR mit dem Zusammenfassungstext, ohne Anführungszeichen.$$,
 1),

-- 2. Skill Suggestions
('exercise_skill_suggestions',
 'Fähigkeiten-Empfehlungen',
 'Empfiehlt passende Fähigkeiten + Stufen aus dem Katalog für eine Übung.',
$$Du bist Assistent für einen Kampfsport-Trainer.
Analysiere diese Übung und empfehle passende Fähigkeiten aus dem Katalog.

Übung: {{exercise_title}}
Fokusbereich: {{exercise_focus_area}}
Ziel: {{exercise_goal}}
Durchführung: {{exercise_execution}}

Verfügbare Fähigkeiten:
{{skills_catalog}}

Wähle maximal 5 passende Fähigkeiten. Für jede gib an:
- skill_id (aus der Liste)
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
- intensity: Trainingsintensität (niedrig|mittel|hoch)

Antworte NUR als JSON-Array:
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch"}]

Wenn keine Fähigkeit passt, antworte mit [].$$,
 'exercise', 'json', true, NULL, 2),

-- 3. Category Suggestions
('exercise_category_suggestions',
 'Kategorie-Empfehlungen',
 'Empfiehlt Fokusbereich, Stilrichtung und Zielgruppe für eine Übung.',
$$Du bist Assistent für einen Kampfsport-Trainer.
Ordne diese Übung in die Katalog-Struktur ein.

Übung: {{exercise_title}}
Beschreibung: {{exercise_goal}}
Durchführung: {{exercise_execution}}

Verfügbare Fokusbereiche: {{focus_areas_catalog}}
Verfügbare Stilrichtungen: {{style_directions_catalog}}
Verfügbare Zielgruppen: {{target_groups_catalog}}

Wähle die passendsten Zuordnungen. Antworte NUR als JSON:
{
  "focus_areas": [{"id": 1, "is_primary": true}],
  "style_directions": [{"id": 2, "is_primary": true}],
  "target_groups": [{"id": 5, "is_primary": true}]
}$$,
 'exercise', 'json', true, NULL, 3),

-- 4. Matrix Level Description
('model_skill_level_description',
 'Fähigkeitsmatrix-Stufenbeschreibung',
 'Generiert Beschreibungen für Stufen in der Fähigkeitsmatrix.',
$$Du bist Fachexperte für {{focus_area}} und erstellst Lernziel-Beschreibungen.

Modell: {{model_name}}
Fokusbereich: {{focus_area}}
Zielgruppe: {{target_group}}
Fähigkeit: {{skill_name}}
Stufenanzahl: {{level_count}}
Stufen: {{level_names}}

Beschreibe für JEDE Stufe konkret und beobachtbar, was ein Schüler auf dieser Stufe
der Fähigkeit "{{skill_name}}" können soll.

Antworte NUR als JSON-Array ({{level_count}} Einträge):
[
  {"level_number": 1, "description": "...", "observable_criteria": "..."},
  {"level_number": 2, "description": "...", "observable_criteria": "..."}
]$$,
 'matrix', 'json', true, NULL, 4),

-- 5. Wiki Import Cleanup
('wiki_import_cleanup',
 'Wiki-Text-Bereinigung',
 'Bereinigt importierten Wikitext in lesbares, strukturiertes Deutsch.',
$$Bereinige folgenden Text aus einem Trainings-Wiki für die Darstellung in einer modernen App.

Original-Text:
{{wiki_raw_text}}

Regeln:
- Entferne alle Wiki-Markup-Syntax ([[Links]], {{Templates}}, ==Überschriften==)
- Behalte den fachlichen Inhalt vollständig
- Schreibe in klarem, professionellem Deutsch
- Strukturiere mit Absätzen (nicht mit Wiki-Markup)
- Maximal 1000 Zeichen

Antworte NUR mit dem bereinigten Text.$$,
 'import', 'text', true, NULL, 5)

ON CONFLICT (slug) DO NOTHING;

RAISE NOTICE 'Migration 020 completed successfully (AI Prompt System)';

END $$;
```

---

## 3. Platzhalter-Katalog

### 3.1 Verfügbare Platzhalter

Alle `{{Platzhalter}}` werden vom Backend-Service `prompt_resolver.py` aufgelöst.

**Kontext: exercise** (für exercise_summary, skill_suggestions, category_suggestions)

| Platzhalter | Beschreibung | Beispielwert |
|-------------|--------------|--------------|
| `{{exercise_title}}` | Titel der Übung | "Maai - Distanzübung" |
| `{{exercise_goal}}` | Ziel (erste 500 Zeichen) | "Distanzgefühl entwickeln..." |
| `{{exercise_execution}}` | Durchführung (erste 500 Zeichen) | "1. Partnerwahl..." |
| `{{exercise_preparation}}` | Vorbereitung | "Matten auslegen..." |
| `{{exercise_focus_area}}` | Primärer Fokusbereich | "Karate" |
| `{{exercise_duration}}` | Dauer | "15-20 min" |
| `{{exercise_group_size}}` | Gruppengröße | "8-12 Personen" |
| `{{skills_catalog}}` | Liste aller Skills: "ID Name (Kategorie)" | "- ID 1: Dachi Waza (Kihon)\n..." |
| `{{focus_areas_catalog}}` | Liste aller Fokusbereiche | "- ID 1: Karate\n..." |
| `{{style_directions_catalog}}` | Liste aller Stilrichtungen | "- ID 1: Shotokan\n..." |
| `{{target_groups_catalog}}` | Liste aller Zielgruppen | "- ID 1: Breitensportler\n..." |

**Kontext: matrix** (für model_skill_level_description)

| Platzhalter | Beschreibung | Beispielwert |
|-------------|--------------|--------------|
| `{{model_name}}` | Name des Reifegradmodells | "Karate Shotokan Breitensport" |
| `{{focus_area}}` | Fokusbereich des Modells | "Karate" |
| `{{style_direction}}` | Stilrichtung | "Shotokan" |
| `{{target_group}}` | Zielgruppe | "Breitensportler" |
| `{{skill_name}}` | Fähigkeitsname | "Distanzgefühl" |
| `{{skill_description}}` | Fähigkeitsbeschreibung | "Kontrolle der Kampfdistanz..." |
| `{{level_count}}` | Anzahl der Stufen | "5" |
| `{{level_names}}` | Stufen-Namen kommagetrennt | "Einsteiger, Grundlagen, Aufbau, Fortgeschritten, Experte" |

**Kontext: import**

| Platzhalter | Beschreibung |
|-------------|--------------|
| `{{wiki_raw_text}}` | Roher Wikitext aus MediaWiki |

### 3.2 Platzhalter-Auflösung (Backend)

```python
# backend/prompt_resolver.py

class ExercisePromptContext:
    """Kontext für exercise-bezogene Prompts."""
    def resolve(self, template: str, exercise_data: dict, db) -> str:
        variables = {
            "exercise_title":          exercise_data.get("title", ""),
            "exercise_goal":           exercise_data.get("goal", "")[:500],
            "exercise_execution":      exercise_data.get("execution", "")[:500],
            "exercise_preparation":    exercise_data.get("preparation", ""),
            "exercise_focus_area":     self._get_primary_focus_area(exercise_data, db),
            "exercise_duration":       self._format_duration(exercise_data),
            "exercise_group_size":     self._format_group_size(exercise_data),
            "skills_catalog":          self._format_skills_catalog(db),
            "focus_areas_catalog":     self._format_catalog(db, "focus_areas"),
            "style_directions_catalog":self._format_catalog(db, "style_directions"),
            "target_groups_catalog":   self._format_catalog(db, "target_groups"),
        }
        return self._replace_placeholders(template, variables)
```

### 3.3 Unbekannte Platzhalter

Wenn ein Platzhalter nicht aufgelöst werden kann:
- `{{unknown_key}}` → bleibt als `[NICHT VERFÜGBAR]` im Template
- Warnung im API-Response
- Kein Abbruch des KI-Aufrufs

---

## 4. API-Endpoints

### 4.1 Übersicht

| Method | Endpoint | Beschreibung |
|--------|----------|--------------|
| GET | `/admin/ai-prompts` | Liste aller Prompts (Admin) |
| GET | `/admin/ai-prompts/{id}` | Prompt-Detail |
| POST | `/admin/ai-prompts` | Neuen Prompt anlegen |
| PUT | `/admin/ai-prompts/{id}` | Prompt bearbeiten |
| DELETE | `/admin/ai-prompts/{id}` | Prompt löschen (nur custom, nicht system) |
| POST | `/admin/ai-prompts/{id}/reset` | Auf System-Default zurücksetzen |
| POST | `/admin/ai-prompts/{id}/preview` | Platzhalter auflösen ohne KI-Call |
| GET | `/admin/ai-prompts/placeholders` | Platzhalter-Katalog mit Beschreibungen |
| POST | `/admin/ai-prompts/test` | Prompt mit Beispiel-Daten testen (echter KI-Call) |

### 4.2 `GET /admin/ai-prompts`

**Response:** `200 OK`
```json
[
  {
    "id": 1,
    "slug": "exercise_summary",
    "display_name": "Übungs-Zusammenfassung",
    "description": "Generiert eine kurze Zusammenfassung...",
    "category": "exercise",
    "output_format": "text",
    "active": true,
    "is_system_default": true,
    "is_modified": false,
    "sort_order": 1
  }
]
```

`is_modified`: true wenn `template != default_template`

---

### 4.3 `PUT /admin/ai-prompts/{id}`

**Request Body:**
```json
{
  "template": "Du bist ein erfahrener Karate-Trainer...\n{{exercise_title}}...",
  "active": true,
  "display_name": "Übungs-Zusammenfassung (angepasst)"
}
```

**Response:** `200 OK` (Prompt-Objekt)

**Constraints:**
- Nur `template`, `display_name`, `description`, `active`, `sort_order` änderbar
- `slug`, `output_format`, `category` nicht änderbar (würde Code brechen)
- System-Default-Backup bleibt immer erhalten

---

### 4.4 `POST /admin/ai-prompts/{id}/preview`

Löst Platzhalter auf und zeigt das finale Prompt – **OHNE KI-Call**.

**Request Body:**
```json
{
  "context": "exercise",
  "example_data": {
    "exercise_id": 42
  }
}
```

**Response:** `200 OK`
```json
{
  "resolved_template": "Du bist Assistent für einen Kampfsport-Trainer.\nÜbung: Maai - Distanzübung\n...",
  "placeholders_resolved": ["exercise_title", "exercise_goal"],
  "placeholders_missing": [],
  "estimated_tokens": 320
}
```

---

### 4.5 `GET /admin/ai-prompts/placeholders`

Liefert den vollständigen Platzhalter-Katalog.

**Response:** `200 OK`
```json
{
  "categories": {
    "exercise": [
      {
        "key": "exercise_title",
        "placeholder": "{{exercise_title}}",
        "description": "Titel der Übung",
        "example": "Maai - Distanzübung",
        "required_context": "exercise_id oder title direkt"
      }
    ],
    "matrix": [...],
    "import": [...]
  }
}
```

---

## 5. Verwendung in Backend-Code

### 5.1 Standard-Pattern für KI-Aufrufe

```python
# backend/services/ai_service.py

async def run_ai_prompt(
    slug: str,
    context_data: dict,
    db,
    openrouter_key: str
) -> dict:
    """
    Lädt Prompt aus DB, löst Platzhalter auf, ruft KI auf.
    Wirft AiNotConfiguredError wenn key fehlt oder Prompt inaktiv.
    """
    # 1. Prompt laden
    prompt = db.fetchrow("SELECT * FROM ai_prompts WHERE slug=$1 AND active=true", slug)
    if not prompt:
        raise AiNotConfiguredError(f"Prompt '{slug}' nicht gefunden oder inaktiv")

    # 2. Platzhalter auflösen
    resolved = resolve_placeholders(
        template=prompt["template"],
        context=context_data,
        db=db
    )

    # 3. KI-Call
    response = await call_openrouter(
        prompt=resolved,
        model=settings.openrouter_model,
        api_key=openrouter_key
    )

    # 4. JSON validieren (wenn output_format='json')
    if prompt["output_format"] == "json":
        response = parse_and_validate_json(response, prompt["output_schema"])

    return {
        "output": response,
        "prompt_slug": slug,
        "ai_generated": True,
        "model": settings.openrouter_model
    }
```

### 5.2 Aufruf in exercises Router

```python
# backend/routers/exercises.py

@router.post("/exercises/ai/suggest")
async def suggest_for_exercise(data: ExerciseSuggestRequest, session=Depends(require_auth)):
    result = {}

    # Summary
    try:
        summary = await ai_service.run_ai_prompt(
            slug="exercise_summary",
            context_data={"exercise": data.dict()},
            db=db,
            openrouter_key=settings.openrouter_api_key
        )
        result["summary"] = summary
    except AiNotConfiguredError:
        result["summary"] = None

    # Skills
    try:
        skills = await ai_service.run_ai_prompt(
            slug="exercise_skill_suggestions",
            context_data={"exercise": data.dict()},
            db=db,
            openrouter_key=settings.openrouter_api_key
        )
        result["skills"] = skills
    except AiNotConfiguredError:
        result["skills"] = None

    return result
```

---

## 6. Frontend: Admin-UI

### 6.1 Route

```
/admin/ai-prompts         → AdminAiPromptsPage (Liste)
/admin/ai-prompts/{id}    → AdminAiPromptDetailPage (Edit)
```

### 6.2 Layout (Prompt-Editor)

```
┌───────────────────────────────────────────────┐
│ ✨ KI-Prompts                    [+ Neu]       │
│ ─────────────────────────────────────────────  │
│ exercise_summary        [Aktiv]  [Bearbeiten]  │
│ exercise_skill_suggestions [Aktiv] [Bearbeiten]│
│ model_skill_level_description [Aktiv]          │
└───────────────────────────────────────────────┘

── Detailansicht ──────────────────────────────────
┌───────────────────────────────────────────────┐
│ Übungs-Zusammenfassung                        │
│ slug: exercise_summary  ·  Kategorie: exercise │
│ ─────────────────────────────────────────────  │
│ Template:                                      │
│ ┌────────────────────────────────────────────┐ │
│ │ Du bist Assistent für einen Kampfsport-    │ │
│ │ Trainer. ...                               │ │
│ │ {{exercise_title}}                         │ │ ← Syntaxhighlight
│ │ {{exercise_goal}}                          │ │
│ └────────────────────────────────────────────┘ │
│ [Verfügbare Platzhalter ▼]  [Vorschau]         │
│ ─────────────────────────────────────────────  │
│ [Mit Beispiel testen]  [Original wiederherstellen]│
│                                    [Speichern] │
└───────────────────────────────────────────────┘
```

**Syntax-Highlighting:** `{{placeholder}}` in Farbe hervorheben.
**Platzhalter-Panel:** Ausklappbare Liste aller verfügbaren Platzhalter (aus `/admin/ai-prompts/placeholders`).

---

## 7. Zusammenspiel KI_FEATURES_SPEC ↔ AI_PROMPT_SYSTEM_SPEC

Die `KI_FEATURES_SPEC.md` beschreibt die **User-Flows** (wann wird KI angeboten,
wie sieht die Bestätigung aus).

Die `AI_PROMPT_SYSTEM_SPEC.md` (diese Datei) beschreibt die **technische Basis**
(DB-Schema, Platzhalter-System, Admin-UI).

```
KI_FEATURES_SPEC:     POST /exercises/ai/suggest
                              │
                              ▼
AI_PROMPT_SYSTEM_SPEC:  ai_service.run_ai_prompt("exercise_summary", ...)
                              │
                              ▼
                        DB: ai_prompts WHERE slug='exercise_summary'
                              │
                              ▼
                        Template + {{Platzhalter}} auflösen
                              │
                              ▼
                        OpenRouter API
```

---

## 8. Prompt-Kaskaden (geplant — nicht implementiert)

**Ziel:** Vorlagen, die andere Prompts einbinden oder in feste Stufen (System → Fach → Ausgabeformat) zerlegt werden — ohne die DB-Templates mit duplizierten Fliesstexten zu zersplittern.

**Konzeptskizze:**
- Optional neues Feld `base_slug` oder eigene Tabelle `ai_prompt_composition` (Reihenfolge, Rolle: `system|user|prepend`).
- Platzhaltersyntax z. B. `{{include_prompt:slug}}` mit **maximaler Verschachtelungstiefe** und Zykluserkennung.
- Auflösungsreihenfolge: (1) eingebundene Slugs expandieren, (2) Kontext-Variablen wie heute ersetzen.

Bis zur Umsetzung bleiben zusammengesetzte Anweisungen im **einen** Template pro Slug (wie `exercise_skill_suggestions` mit `{{skills_catalog}}`).

---

**Version:** 1.1  
**Datum:** 2026-05-30  
**Status:** Teile umgesetzt (DB 067/069, Resolver, Superadmin-API + UI); Kaskaden offen
