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`**