Implement ExerciseFormAiPromptContext and Refactor AI Prompt Job Functionality
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m23s

- Introduced `ExerciseFormAiPromptContext` for unified handling of prompt-related data, enhancing the admin preview and exercise API.
- Added `run_exercise_form_ai_suggestion` function to streamline AI suggestion processing, integrating with the OpenRouter.
- Updated various modules to utilize the new context model, improving code clarity and reducing redundancy.
- Incremented application version to 0.8.162 and updated changelog to reflect these changes, including migration details and new functionality.
This commit is contained in:
Lars 2026-05-22 18:47:09 +02:00
parent 93b8d09d05
commit 5331eab39c
8 changed files with 168 additions and 85 deletions

View File

@ -139,7 +139,7 @@ flowchart LR
| Phase | Inhalt |
|-------|--------|
| **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. |
| **P1** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **`ExerciseFormAiPromptContext`** in `ai_prompt_context.py`; **`run_exercise_form_ai_suggestion`**; Übungs-API und Admin-Vorschau nutzen denselben Kontext. |
| **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. |
@ -159,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`, `backend/ai_prompt_job.py`
- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`, `backend/ai_prompt_context.py`, `backend/ai_prompt_job.py`
---

View File

@ -0,0 +1,86 @@
"""
Gemeinsame Pydantic-Modelle fuer Uebungs-KI-Kontext (Formularfelder Prompt-Platzhalter).
Keine Imports aus exercise_ai vermeidet Zirkelimporte mit ai_prompt_job / exercise_ai.
"""
from __future__ import annotations
from typing import List, Optional, Sequence, Tuple
from pydantic import BaseModel, Field
class ExerciseFormAiFocusRow(BaseModel):
"""Fokusbereich fuer Skill-Retrieval (ai_skill_retrieval_profiles)."""
focus_area_id: int = Field(..., ge=1)
is_primary: Optional[bool] = False
class ExerciseFormAiPromptContext(BaseModel):
"""
Inhaltliche Eingabe fuer Uebungs-Prompts (Kurzfassung / Skill-Vorschlaege).
Wird genutzt von Admin-Prompt-Vorschau und POST /exercises/ai/suggest (via Mapping).
"""
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]
@classmethod
def from_api_suggest(
cls,
*,
title: Optional[str] = None,
goal: Optional[str] = None,
execution: Optional[str] = None,
focus_area_hint: Optional[str] = None,
focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None,
) -> ExerciseFormAiPromptContext:
"""Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint)."""
hint = (focus_area_hint or "").strip() or None
return cls(
title=(title or "").strip(),
goal=goal,
execution=execution,
focus_hint=hint,
focus_areas_context=list(focus_areas_context) if focus_areas_context else None,
)
@classmethod
def from_focus_tuples(
cls,
*,
title: str = "",
goal: Optional[str] = None,
execution: Optional[str] = None,
focus_hint: Optional[str] = None,
focus_tuples: Optional[Sequence[Tuple[int, bool]]] = None,
) -> ExerciseFormAiPromptContext:
rows = None
if focus_tuples:
rows = [
ExerciseFormAiFocusRow(focus_area_id=int(fid), is_primary=bool(prim))
for fid, prim in focus_tuples
]
return cls(
title=(title or "").strip(),
goal=goal,
execution=execution,
focus_hint=(focus_hint or "").strip() or None,
focus_areas_context=rows,
)
__all__ = [
"ExerciseFormAiFocusRow",
"ExerciseFormAiPromptContext",
]

View File

@ -1,47 +1,16 @@
"""
KI-Prompt Jobs: validierter Kontext (Pydantic) + Zugriff auf gemeinsame Resolver.
KI-Prompt Jobs: Resolver + oeffentliche Fassade fuer Uebungs-KI-Aufrufe.
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).
Importiert exercise_ai fuer Platzhalter-Builder und OpenRouter-Orchestrierung.
"""
from __future__ import annotations
from typing import Dict, List, Optional, Tuple
from pydantic import BaseModel, Field
from typing import Any, Dict
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
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.
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] = ""
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(
@ -55,8 +24,31 @@ def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptCon
)
def run_exercise_form_ai_suggestion(
cur,
ctx: ExerciseFormAiPromptContext,
*,
want_summary: bool,
want_skills: bool,
) -> Dict[str, Any]:
"""
Fuehrt Uebungs-KI aus (OpenRouter) ein Einstieg fuer Router und kuenftige Jobs.
``ctx`` = Formularinhalt; ``want_*`` = welche Prompt-Slugs angefragt werden.
"""
from exercise_ai import run_exercise_ai_suggestion
return run_exercise_ai_suggestion(
cur,
form_ctx=ctx,
want_summary=want_summary,
want_skills=want_skills,
)
__all__ = [
"ExerciseFormAiFocusRow",
"ExerciseFormAiPromptContext",
"resolve_exercise_form_variables",
"run_exercise_form_ai_suggestion",
]

View File

@ -24,6 +24,7 @@ from openrouter_chat import (
openrouter_chat_completion,
)
from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
_LOGGER = logging.getLogger("shinkan.exercise_ai")
@ -682,16 +683,18 @@ def _require_openrouter_key() -> str:
def run_exercise_ai_suggestion(
cur,
*,
title: Optional[str],
goal: Optional[str],
execution: Optional[str],
focus_area_hint: Optional[str],
focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None,
form_ctx: ExerciseFormAiPromptContext,
want_summary: bool,
want_skills: bool,
) -> Dict[str, Any]:
key = _require_openrouter_key()
title = form_ctx.title
goal = form_ctx.goal
execution = form_ctx.execution
focus_area_hint = form_ctx.focus_hint
focus_areas_context = form_ctx.focus_area_tuples()
g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution)
if not (g_plain.strip() or e_plain.strip()):

View File

@ -12,7 +12,8 @@ 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_context import ExerciseFormAiPromptContext
from ai_prompt_job import resolve_exercise_form_variables
from ai_prompt_runtime import render_ai_prompt_template_for_row
from db import get_cursor, get_db, r2d
from prompt_resolver import exercise_placeholder_catalog
@ -60,17 +61,8 @@ class AiPromptUpdateBody(BaseModel):
openrouter_model: Optional[str] = Field(None, max_length=200)
class AiPromptPreviewFocus(BaseModel):
focus_area_id: int = Field(..., ge=1)
is_primary: Optional[bool] = False
class AiPromptPreviewBody(BaseModel):
title: Optional[str] = ""
goal: Optional[str] = None
execution: Optional[str] = None
focus_hint: Optional[str] = None
focus_areas_context: Optional[List[AiPromptPreviewFocus]] = None
class AiPromptPreviewBody(ExerciseFormAiPromptContext):
"""Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint)."""
@router.get("/api/admin/ai-prompts/catalog/placeholders")
@ -228,8 +220,7 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
warn: Optional[str] = None
if slug in ("exercise_summary", "exercise_skill_suggestions"):
try:
pf_ctx = ExerciseFormAiPromptContext.model_validate(body.model_dump())
vars_map = resolve_exercise_form_variables(cur, slug, pf_ctx)
vars_map = resolve_exercise_form_variables(cur, slug, body)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
elif slug == "pipeline":

View File

@ -35,7 +35,8 @@ from tenant_context import TenantContext, get_tenant_context, get_tenant_context
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
from media_legal_hold import assert_not_under_legal_hold
from exercise_ai import run_exercise_ai_suggestion
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_rich_text import (
RICH_HTML_EXERCISE_FIELDS,
@ -358,11 +359,8 @@ class ExerciseMediaFromAsset(BaseModel):
media_type: Optional[str] = None
class ExerciseAiFocusCtx(BaseModel):
"""Fokusbereich fuer Skill-Kataloggewichte (Migration 068 ai_skill_retrieval_profiles)."""
focus_area_id: int = Field(..., ge=1)
is_primary: Optional[bool] = False
class ExerciseAiFocusCtx(ExerciseFormAiFocusRow):
"""Alias fuer OpenAPI — identisch zu ExerciseFormAiFocusRow."""
class ExerciseAiSuggestBody(BaseModel):
@ -370,7 +368,7 @@ class ExerciseAiSuggestBody(BaseModel):
goal: Optional[str] = Field(None, max_length=64000)
execution: Optional[str] = Field(None, max_length=128000)
focus_area_hint: Optional[str] = Field(None, max_length=1200)
focus_areas_context: Optional[list[ExerciseAiFocusCtx]] = Field(
focus_areas_context: Optional[list[ExerciseFormAiFocusRow]] = Field(
None,
description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung",
)
@ -383,6 +381,15 @@ class ExerciseAiSuggestBody(BaseModel):
raise ValueError("Mindestens include_summary oder include_skills aktivieren.")
return self
def to_form_context(self) -> ExerciseFormAiPromptContext:
return ExerciseFormAiPromptContext.from_api_suggest(
title=self.title,
goal=self.goal,
execution=self.execution,
focus_area_hint=self.focus_area_hint,
focus_areas_context=self.focus_areas_context,
)
class ExerciseAiRegenerateBody(BaseModel):
"""Welche Artefakte neu angefragt werden sollen."""
@ -2306,17 +2313,9 @@ def exercise_ai_suggest_endpoint(
_ = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
fctx = None
if body.focus_areas_context:
fctx = [(x.focus_area_id, bool(x.is_primary)) for x in body.focus_areas_context]
payload = run_exercise_ai_suggestion(
payload = run_exercise_form_ai_suggestion(
cur,
title=(body.title or "").strip(),
goal=body.goal,
execution=body.execution,
focus_area_hint=(body.focus_area_hint or "").strip() or None,
focus_areas_context=fctx,
body.to_form_context(),
want_summary=body.include_summary,
want_skills=body.include_skills,
)
@ -2344,13 +2343,16 @@ def exercise_ai_regenerate_endpoint(
focus = _focus_area_hint_from_detail(exercise)
fctx = _focus_areas_ai_ctx_from_detail(exercise)
payload = run_exercise_ai_suggestion(
cur,
ctx = ExerciseFormAiPromptContext.from_focus_tuples(
title=str(exercise.get("title") or "").strip(),
goal=exercise.get("goal"),
execution=exercise.get("execution"),
focus_area_hint=focus or None,
focus_areas_context=fctx or None,
focus_hint=focus or None,
focus_tuples=fctx or None,
)
payload = run_exercise_form_ai_suggestion(
cur,
ctx,
want_summary=want_summary,
want_skills=want_skills,
)

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.161"
APP_VERSION = "0.8.162"
BUILD_DATE = "2026-05-31"
DB_SCHEMA_VERSION = "20260531070"
@ -20,13 +20,14 @@ MODULE_VERSIONS = {
"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.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"ai_prompt_job": "0.1.0", # ExerciseFormAiPromptContext, resolve_exercise_form_variables — P1 Kontext-Schnittstelle
"ai_prompt_job": "0.2.0", # run_exercise_form_ai_suggestion; Kontext in ai_prompt_context
"ai_prompt_context": "0.1.0", # ExerciseFormAiPromptContext — Formularfelder fuer Uebungs-Prompts
"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.3", # exercise_ai: OpenRouter-Modell pro Prompt-Slug; Response models_by_slug
"exercises": "2.31.4", # ai/suggest + regenerate: ExerciseFormAiPromptContext via run_exercise_form_ai_suggestion
"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 +42,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.162",
"date": "2026-05-31",
"changes": [
"KI Prompt P1 abgeschlossen: ai_prompt_context (Formular-Kontext), run_exercise_form_ai_suggestion als gemeinsamer Einstieg;",
"POST /exercises/ai/suggest und regenerate bauen ExerciseFormAiPromptContext; Admin-Vorschau nutzt dasselbe Modell.",
],
},
{
"version": "0.8.161",
"date": "2026-05-31",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-30
**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`).
**Stand:** 2026-05-31
**App-Version / DB-Schema:** App **`0.8.162`** (KI Prompt P1: gemeinsamer Formular-Kontext `ExerciseFormAiPromptContext`, `run_exercise_form_ai_suggestion`); Zielarchitektur **`.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md`**. DB: **`backend/version.py`** → `DB_SCHEMA_VERSION` (aktuell `20260531070`).
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,12 +89,12 @@ 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.160**)
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.162**)
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0P4.
- **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 / 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)
- **Kontext / Job:** **`ai_prompt_context`** — **`ExerciseFormAiPromptContext`** (Titel, Ziel, Durchführung, Fokus — ein Modell fuer Admin-Vorschau und Übungs-API); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**, **`resolve_exercise_form_variables`**; **`ai_prompt_runtime`** — Laden/Rendern aus DB; **`exercise_ai`** — OpenRouter nach Rendern
- **DB:** Migration **`067`** **`ai_prompts`**; **`069`** **`default_template`**; **`068`** **`ai_skill_retrieval_profiles`**; **`070`** **`openrouter_model`** (optional pro Prompt, Fallback **`OPENROUTER_MODEL`**)
- **`exercise_ai`:** Gewichtungen, KategorieAnteilCaps (~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`**
- **Diagnose bei leerem Dialog / Fehlern:** Umgebungsvariable **`SHINKAN_AI_DEBUG=1`** auf der API; in den Logs dann **`AI_DEBUG`** (`shinkan.exercise_ai`) und **`[AI_DEBUG/openrouter]`** (`shinkan.openrouter`) mit Prompt-Längen, Token-Zahlen und ggf. JSON-Parse-Anfang