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)} /> +
+ + setDraftOpenrouterModel(e.target.value)} + /> +