diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
index b26eef7..c92003e 100644
--- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
+++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
@@ -8,6 +8,7 @@
**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
diff --git a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
index b167142..26e789c 100644
--- a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
+++ b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
@@ -140,7 +140,7 @@ flowchart LR
|-------|--------|
| **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. |
+| **P2** | Versionierung oder Audit-Spalten; **teilweise:** optionales OpenRouter-Modell pro Zeile (`openrouter_model`, Migration 070, Fallback `OPENROUTER_MODEL`); weitere Overrides (Temperatur) offen. |
| **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. |
diff --git a/.env.example b/.env.example
index 91f9c07..66c24e7 100644
--- a/.env.example
+++ b/.env.example
@@ -34,6 +34,8 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_MODEL=anthropic/claude-sonnet-4
+# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
+# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).
diff --git a/backend/ai_prompt_job.py b/backend/ai_prompt_job.py
index 7258e8d..b5e602f 100644
--- a/backend/ai_prompt_job.py
+++ b/backend/ai_prompt_job.py
@@ -24,6 +24,10 @@ class ExerciseFormAiPromptContext(BaseModel):
"""
Eingabe fuer Uebungsbezogene Prompts (Kurzfassung / Skill-JSON-Vorschlag).
Entspricht fachlich dem Preview-Body unter /api/admin/ai-prompts/*/preview.
+
+ Abgrenzung: POST /exercises/ai/suggest nutzt ExerciseAiSuggestBody mit include_summary /
+ include_skills usw.; dieses Modell bildet nur die gemeinsamen Formularfelder — wie die
+ Admin-Vorschau-Body (AiPromptPreviewBody).
"""
title: Optional[str] = ""
diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py
index b0f805d..7a52d22 100644
--- a/backend/ai_prompt_runtime.py
+++ b/backend/ai_prompt_runtime.py
@@ -45,7 +45,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
if active_only:
cur.execute(
"""
- SELECT slug, display_name, template, output_format, active
+ SELECT slug, display_name, template, output_format, active, openrouter_model
FROM ai_prompts
WHERE slug = %s AND active = true
""",
@@ -54,7 +54,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
else:
cur.execute(
"""
- SELECT slug, display_name, template, output_format, active
+ SELECT slug, display_name, template, output_format, active, openrouter_model
FROM ai_prompts
WHERE slug = %s
""",
diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py
index 786385c..8b41509 100644
--- a/backend/exercise_ai.py
+++ b/backend/exercise_ai.py
@@ -16,7 +16,13 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence,
from fastapi import HTTPException
-from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
+from openrouter_chat import (
+ OpenRouterError,
+ default_openrouter_model_id,
+ effective_openrouter_model_for_prompt_row,
+ normalize_openrouter_env,
+ openrouter_chat_completion,
+)
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
@@ -663,14 +669,14 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
return out[:5]
-def _require_openrouter() -> Tuple[str, str]:
- key, model = normalize_openrouter_env()
+def _require_openrouter_key() -> str:
+ key, _ = normalize_openrouter_env()
if not key:
raise HTTPException(
status_code=503,
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
)
- return key, model
+ return key
def run_exercise_ai_suggestion(
@@ -684,7 +690,7 @@ def run_exercise_ai_suggestion(
want_summary: bool,
want_skills: bool,
) -> Dict[str, Any]:
- key, model = _require_openrouter()
+ key = _require_openrouter_key()
g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution)
@@ -697,7 +703,8 @@ def run_exercise_ai_suggestion(
t_title = (title or "").strip()
focus = (focus_area_hint or "").strip()
- result: Dict[str, Any] = {"model": model}
+ result: Dict[str, Any] = {}
+ models_by_slug: Dict[str, str] = {}
if _ai_debug_on():
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
@@ -733,6 +740,8 @@ def run_exercise_ai_suggestion(
status_code=503,
detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.",
) from None
+ model_summary = effective_openrouter_model_for_prompt_row(prow)
+ models_by_slug["exercise_summary"] = model_summary
prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
@@ -741,7 +750,7 @@ def run_exercise_ai_suggestion(
len(rendered.placeholders_remaining),
)
try:
- raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
+ raw = openrouter_chat_completion(api_key=key, model=model_summary, user_content=prompt)
except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
if _ai_debug_on():
@@ -754,7 +763,7 @@ def run_exercise_ai_suggestion(
)
if len(text) > _MAX_SUMMARY_CHARS:
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…"
- result["summary"] = {"text": text, "ai_generated": True, "model": model}
+ result["summary"] = {"text": text, "ai_generated": True, "model": model_summary}
if want_skills:
try:
@@ -776,6 +785,8 @@ def run_exercise_ai_suggestion(
status_code=503,
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
) from None
+ model_skills = effective_openrouter_model_for_prompt_row(srow)
+ models_by_slug["exercise_skill_suggestions"] = model_skills
prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
@@ -791,7 +802,7 @@ def run_exercise_ai_suggestion(
try:
raw = openrouter_chat_completion(
api_key=key,
- model=model,
+ model=model_skills,
user_content=prompt,
system_content=sys_hint,
temperature=0.15,
@@ -829,6 +840,14 @@ def run_exercise_ai_suggestion(
result["skills"] = skills
+ result["models_by_slug"] = models_by_slug
+ if want_skills:
+ result["model"] = models_by_slug["exercise_skill_suggestions"]
+ elif want_summary:
+ result["model"] = models_by_slug["exercise_summary"]
+ else:
+ result["model"] = default_openrouter_model_id()
+
return result
diff --git a/backend/migrations/070_ai_prompts_openrouter_model.sql b/backend/migrations/070_ai_prompts_openrouter_model.sql
new file mode 100644
index 0000000..2977037
--- /dev/null
+++ b/backend/migrations/070_ai_prompts_openrouter_model.sql
@@ -0,0 +1,7 @@
+-- Migration 070: optionales OpenRouter-Modell pro Prompt-Zeile
+-- Leer/NULL → Umgebungsvariable OPENROUTER_MODEL (wie bisher).
+
+ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS openrouter_model VARCHAR(200);
+
+COMMENT ON COLUMN ai_prompts.openrouter_model IS
+ 'Optional: OpenRouter model id (z.B. anthropic/claude-3.5-haiku); NULL = OPENROUTER_MODEL aus Env';
diff --git a/backend/openrouter_chat.py b/backend/openrouter_chat.py
index 96a1d2f..8a7dd88 100644
--- a/backend/openrouter_chat.py
+++ b/backend/openrouter_chat.py
@@ -6,7 +6,7 @@ from __future__ import annotations
import json
import logging
import os
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Mapping, Optional
import httpx
@@ -203,3 +203,22 @@ def normalize_openrouter_env() -> tuple[str, str]:
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
return key, model
+
+
+def default_openrouter_model_id() -> str:
+ """Standard-Modell aus OPENROUTER_MODEL (ohne API-Key zu pruefen)."""
+ _, model = normalize_openrouter_env()
+ return model
+
+
+def effective_openrouter_model_for_prompt_row(row: Optional[Mapping[str, Any]]) -> str:
+ """
+ Pro-Prompt-Override in ai_prompts.openrouter_model, sonst Env-Default.
+
+ `row` kann eine partial Row aus load_ai_prompt_row sein (Felder slug, openrouter_model, …).
+ """
+ if row:
+ custom = str(row.get("openrouter_model") or "").strip()
+ if custom:
+ return custom
+ return default_openrouter_model_id()
diff --git a/backend/routers/ai_prompts_admin.py b/backend/routers/ai_prompts_admin.py
index b1d1241..dfefaec 100644
--- a/backend/routers/ai_prompts_admin.py
+++ b/backend/routers/ai_prompts_admin.py
@@ -40,7 +40,7 @@ def _fetch_prompt_any(cur, prompt_id: int) -> Dict[str, Any]:
cur.execute(
"""
SELECT id, slug, display_name, description, template, category, output_format,
- output_schema, is_system_default, default_template,
+ output_schema, is_system_default, default_template, openrouter_model,
active, sort_order, created_at, updated_at
FROM ai_prompts WHERE id = %s
""",
@@ -57,6 +57,7 @@ class AiPromptUpdateBody(BaseModel):
active: Optional[bool] = None
display_name: Optional[str] = Field(None, max_length=200)
description: Optional[str] = Field(None, max_length=8000)
+ openrouter_model: Optional[str] = Field(None, max_length=200)
class AiPromptPreviewFocus(BaseModel):
@@ -86,7 +87,7 @@ def list_ai_prompts(session: dict = Depends(_require_superadmin)):
cur.execute(
"""
SELECT id, slug, display_name, description, category, output_format, active,
- sort_order, is_system_default, default_template
+ sort_order, is_system_default, default_template, openrouter_model
FROM ai_prompts
ORDER BY sort_order ASC NULLS LAST, id ASC
"""
@@ -150,16 +151,25 @@ def update_ai_prompt(
next_desc = body.description if body.description is not None else old.get("description") or ""
next_desc = (next_desc or "").strip()
+ next_openrouter = old.get("openrouter_model")
+ if body.openrouter_model is not None:
+ cand = body.openrouter_model.strip() if isinstance(body.openrouter_model, str) else ""
+ if any(c in cand for c in ("\r", "\n", "\t")):
+ raise HTTPException(status_code=400, detail="openrouter_model: keine Steuerzeichen erlaubt.")
+ next_openrouter = cand or None
+
cur.execute(
"""
UPDATE ai_prompts
- SET template = %s, active = %s, display_name = %s, description = %s, updated_at = NOW()
+ SET template = %s, active = %s, display_name = %s, description = %s,
+ openrouter_model = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, slug, display_name, description, template, category, output_format,
- output_schema, is_system_default, default_template, active, sort_order,
+ output_schema, is_system_default, default_template, openrouter_model,
+ active, sort_order,
created_at, updated_at
""",
- (next_template, next_active, next_name, next_desc, prompt_id),
+ (next_template, next_active, next_name, next_desc, next_openrouter, prompt_id),
)
row = dict(cur.fetchone())
conn.commit()
@@ -188,7 +198,8 @@ def reset_ai_prompt_template(prompt_id: int, session: dict = Depends(_require_su
SET template = default_template, updated_at = NOW()
WHERE id = %s AND default_template IS NOT NULL
RETURNING id, slug, display_name, description, template, category, output_format,
- output_schema, is_system_default, default_template, active, sort_order,
+ output_schema, is_system_default, default_template, openrouter_model,
+ active, sort_order,
created_at, updated_at
""",
(prompt_id,),
diff --git a/backend/version.py b/backend/version.py
index 41f6ed9..310c8e9 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.160"
-BUILD_DATE = "2026-05-30"
-DB_SCHEMA_VERSION = "20260530069"
+APP_VERSION = "0.8.161"
+BUILD_DATE = "2026-05-31"
+DB_SCHEMA_VERSION = "20260531070"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -19,14 +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.2", # Preview: ai_prompt_job + render_ai_prompt_template_for_row
+ "admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"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.2", # exercise_ai: load_and_render_ai_prompt statt getrennt load+render
+ "exercises": "2.31.3", # exercise_ai: OpenRouter-Modell pro Prompt-Slug; Response models_by_slug
"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
@@ -41,6 +41,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.161",
+ "date": "2026-05-31",
+ "changes": [
+ "Migration 070: ai_prompts.openrouter_model (optional je Prompt; Fallback OPENROUTER_MODEL).",
+ "exercise_ai: effektives OpenRouter-Modell pro Slug; API-Response models_by_slug + model (Skills bevorzugt).",
+ "Superadmin „KI Prompts“: OpenRouter-Modell speicherbar.",
+ ],
+ },
{
"version": "0.8.160",
"date": "2026-05-30",
diff --git a/frontend/src/pages/AdminAiPromptsPage.jsx b/frontend/src/pages/AdminAiPromptsPage.jsx
index de45cd1..768a314 100644
--- a/frontend/src/pages/AdminAiPromptsPage.jsx
+++ b/frontend/src/pages/AdminAiPromptsPage.jsx
@@ -23,6 +23,7 @@ export default function AdminAiPromptsPage() {
const [draftName, setDraftName] = useState('')
const [draftDesc, setDraftDesc] = useState('')
const [draftTemplate, setDraftTemplate] = useState('')
+ const [draftOpenrouterModel, setDraftOpenrouterModel] = useState('')
const [draftActive, setDraftActive] = useState(true)
const [pvTitle, setPvTitle] = useState('Testübung')
@@ -74,6 +75,9 @@ export default function AdminAiPromptsPage() {
setDraftName(d.display_name || '')
setDraftDesc(d.description || '')
setDraftTemplate(d.template || '')
+ setDraftOpenrouterModel(
+ typeof d.openrouter_model === 'string' ? d.openrouter_model : ''
+ )
setDraftActive(!!d.active)
setPvPreview(null)
}
@@ -96,6 +100,7 @@ export default function AdminAiPromptsPage() {
display_name: draftName,
description: draftDesc,
active: draftActive,
+ openrouter_model: draftOpenrouterModel.trim(),
})
await loadList()
const nd = await api.getAdminAiPrompt(detail.id)
@@ -201,6 +206,11 @@ export default function AdminAiPromptsPage() {
inaktiv
) : null}
+ {p.openrouter_model ? (
+
+ Model: {p.openrouter_model}
+
+ ) : null}
{p.is_modified ? (von Referenz abweichend) : null}
@@ -231,6 +241,17 @@ export default function AdminAiPromptsPage() {
onChange={(e) => setDraftDesc(e.target.value)}
/>
+