From 0551bb3d801c347144f7000e8a131abccc5861a6 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 12:19:52 +0200 Subject: [PATCH 1/9] Refactor AI Prompt System and Enhance Functionality - 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. --- .../AI_PROMPT_TARGET_ARCHITECTURE.md | 18 +++--- backend/ai_prompt_job.py | 58 +++++++++++++++++++ backend/ai_prompt_runtime.py | 41 ++++++++++++- backend/exercise_ai.py | 28 ++++----- backend/routers/ai_prompts_admin.py | 26 ++------- backend/version.py | 18 ++++-- docs/HANDOVER.md | 6 +- 7 files changed, 146 insertions(+), 49 deletions(-) create mode 100644 backend/ai_prompt_job.py diff --git a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md index e75de69..b167142 100644 --- a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md +++ b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md @@ -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` --- diff --git a/backend/ai_prompt_job.py b/backend/ai_prompt_job.py new file mode 100644 index 0000000..7258e8d --- /dev/null +++ b/backend/ai_prompt_job.py @@ -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", +] diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py index 4ca8560..b0f805d 100644 --- a/backend/ai_prompt_runtime.py +++ b/backend/ai_prompt_runtime.py @@ -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", ] diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py index 6391740..786385c 100644 --- a/backend/exercise_ai.py +++ b/backend/exercise_ai.py @@ -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( diff --git a/backend/routers/ai_prompts_admin.py b/backend/routers/ai_prompts_admin.py index b0bb55d..b1d1241 100644 --- a/backend/routers/ai_prompts_admin.py +++ b/backend/routers/ai_prompts_admin.py @@ -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, diff --git a/backend/version.py b/backend/version.py index 3a1f6d6..41f6ed9 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 48abe41..4d1d844 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -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 P0–P4. - **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, Kategorie‑Anteil‑Caps (~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`** From 93b8d09d055ed430bb95bad66227dc5fb80ba59e Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 12:37:43 +0200 Subject: [PATCH 2/9] Implement OpenRouter Model Support in AI Prompt System - Added `openrouter_model` field to the `ai_prompts` table, allowing for optional model overrides per prompt. - Updated the `exercise_ai` module to utilize the effective OpenRouter model based on prompt-specific settings, enhancing flexibility in AI interactions. - Enhanced the admin interface to support OpenRouter model configuration for prompts, improving usability for Superadmins. - Incremented application version to 0.8.161 and updated changelog to reflect these changes, including migration details and new functionality. --- .../docs/technical/AI_PROMPT_SYSTEM_SPEC.md | 1 + .../AI_PROMPT_TARGET_ARCHITECTURE.md | 2 +- .env.example | 2 + backend/ai_prompt_job.py | 4 ++ backend/ai_prompt_runtime.py | 4 +- backend/exercise_ai.py | 37 ++++++++++++++----- .../070_ai_prompts_openrouter_model.sql | 7 ++++ backend/openrouter_chat.py | 21 ++++++++++- backend/routers/ai_prompts_admin.py | 23 +++++++++--- backend/version.py | 19 +++++++--- frontend/src/pages/AdminAiPromptsPage.jsx | 21 +++++++++++ 11 files changed, 117 insertions(+), 24 deletions(-) create mode 100644 backend/migrations/070_ai_prompts_openrouter_model.sql 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)} + /> +