diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md index ba8b968..b26eef7 100644 --- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md +++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md @@ -1,8 +1,14 @@ # KI-Prompt-System – Universelle Admin-Konfiguration -**Version:** 1.0 -**Datum:** 2026-04-24 -**Status:** DRAFT +**Version:** 1.1 +**Datum:** 2026-05-30 +**Status:** Kern umgesetzt (`ai_prompts`, `prompt_resolver`, Superadmin-HTTP-API); Kaskaden geplant (Abschnitt 8) + +**Zielbild (Roadmap):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Planung/Rahmen, Phasenplan. + +**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` + **Autor:** Claude Code **Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System @@ -598,6 +604,19 @@ AI_PROMPT_SYSTEM_SPEC: ai_service.run_ai_prompt("exercise_summary", ...) --- -**Version:** 1.0 -**Datum:** 2026-04-24 -**Status:** DRAFT +## 8. Prompt-Kaskaden (geplant — nicht implementiert) + +**Ziel:** Vorlagen, die andere Prompts einbinden oder in feste Stufen (System → Fach → Ausgabeformat) zerlegt werden — ohne die DB-Templates mit duplizierten Fliesstexten zu zersplittern. + +**Konzeptskizze:** +- Optional neues Feld `base_slug` oder eigene Tabelle `ai_prompt_composition` (Reihenfolge, Rolle: `system|user|prepend`). +- Platzhaltersyntax z. B. `{{include_prompt:slug}}` mit **maximaler Verschachtelungstiefe** und Zykluserkennung. +- Auflösungsreihenfolge: (1) eingebundene Slugs expandieren, (2) Kontext-Variablen wie heute ersetzen. + +Bis zur Umsetzung bleiben zusammengesetzte Anweisungen im **einen** Template pro Slug (wie `exercise_skill_suggestions` mit `{{skills_catalog}}`). + +--- + +**Version:** 1.1 +**Datum:** 2026-05-30 +**Status:** Teile umgesetzt (DB 067/069, Resolver, Superadmin-API + UI); Kaskaden offen diff --git a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md new file mode 100644 index 0000000..e75de69 --- /dev/null +++ b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md @@ -0,0 +1,164 @@ +# KI-Prompt-System — Zielarchitektur (Shinkan Jinkendo) + +**Version:** 1.0 +**Datum:** 2026-05-30 +**Status:** VERBINDLICHE ZIELRICHTUNG (Roadmap — nicht alles bereits umgesetzt) +**Ergänzt:** `AI_PROMPT_SYSTEM_SPEC.md` (aktueller Ist-Stand APIs/DB/UI), Mitai-Anleihen aus gleichnamigen Konzepten (Admin-Prompts, Platzhalter) + +--- + +## 1. Zweck + +Dieses Dokument beschreibt das **Zielbild**, damit spätere Arbeiten (**Trainingsplanung**, **mehrstufige Rahmenprogramme**, **Phasen/Streams**, weitere KI-Artefakte) **nicht** zu wiederholten Refaktoren von Übungs-KI oder OpenRouter-Anbindung zwingen. + +**Leitkriterien:** wenige stabile Schnittflächen, Kontext pro Domäne, komponierbare Prompts, gültige Ausgaben, Betrieb ohne Code-Deploy für kleine Tweaks. + +--- + +## 2. Leitprinzipien + +### 2.1 Eine stabile Ausführungsschicht + +Alle produktiven KI-Aufrufe sollten mittelfristig über eine **einheitliche Fassade** laufen: + +- **Eingabe:** `slug` (+ optional Kontext-Arten-Enum), **serialisierter Domän-Kontext** (Pydantic pro Kind), Konfiguration (Modell, Temperatur, … aus Env/DB). +- **Ausgabe:** Text oder validiertes JSON, Metadaten (`model`, `slug`, ggf. `prompt_version`/Hash), strukturierte Fehler. + +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`). + +### 2.2 Trennung: Semantik vs. Transport + +- **Semantik:** Was soll das Modell liefern? Das hängt an **Prompt-Definition**, **Ausgabeformat** (`text`/`json`) und nachvollziehbarer Validierung — nicht am HTTP-Client. +- **Transport:** OpenRouter, Modellwahl, Retry, Timeouts bleiben in einem oder wenigen Hilfsmodulen. + +### 2.3 Kontext-Namespaces für Platzhalter + +Platzhalter und erlaubte Keys sind **pro logischer Kontext-Art** definiert, z. B.: + +- `exercise_form_ai` — heute: Übungsformular-Vorschläge. +- später: `training_unit`, `framework_program_slot`, `import_wiki`, … + +Damit kann der Katalog wachsen, ohne dass alle Keys in einen globalen Soup-Namespace müssen (`exercise_*` vs. `framework_*` ohne Kollisionen). Optional später **präfixierte** Keys (`exercise.title`, `slot.index`). + +### 2.4 Komposition / Kaskade explizit + +**Ziel:** Mehrteilige Prompts („System“–„Nutzer“–Anhänge) und **Einbindung anderer Vorlagen** als **Daten** (Kompositionsmodell), nicht nur als unbearbeiteter Freitext mit `{{include}}`. + +Skizzen (noch nicht vollständig umgesetzt): + +- Tabelle oder JSON-Spalte `composition`/`ai_prompt_segments`: geordnete Segmente mit `role` (`system` \| `user` \| äquivalent zum jeweiligen API-Shape), Quelle (`inline`, `ref_slug`), optional `ref_slug`, Schema-Version. +- Einbindungen mit **Maximaltiefe** und **Zykluserkennung** — keine unbegrenzten Makro-Ketten. + +Bis dahin bleiben zusammenhängende Anweisungen in **einem** DB-Template pro Slug tragbar (`exercise_skill_suggestions` + `{{skills_catalog}}` bleiben gültig). + +--- + +## 3. Zieldatenmodell (Schichten) + +### 3.1 Definition (`ai_prompts` — bereits vorhanden, evolviert) + +| Konzept | Bedeutung | +|--------|-----------| +| `slug`, `category`, `output_format`, `active` | Adressierung & Schalter | +| `template` | aktueller Inhalt | +| `default_template` | Referenz zum Zurücksetzen (Migration **069**) | +| `output_schema` (JSONB) | optional: JSON-Outputs validieren | + +**Ausbaustufen:** + +1. Nur `template`-Text (**heute**, plus Mustache über `prompt_resolver`). +2. Zusätzlich **Versionierung**: Historie oder `template_version`/Audit (wer hat wann geändert). +3. **Segmentierte Composition** wie in Abschnitt 2.4. + +### 3.2 Kontext-Builder pro Domäne + +Pro **Kontext-Art** eine klar genannte Routine (Pattern: registrierbare Builder): + +| Kontext-Art | Beispiel-Input aus der App | Beispiel-Platzhalter / Daten | +|-------------|----------------------------|------------------------------| +| `exercise_form_ai` | Titel, Ziel/Durchführung (HTML→Plain), Fokuskontext, Retrieval-Profil-Influenza | `exercise_*`, `skills_catalog` | +| `training_unit` (geplant) | Sektionen, Zeiten, Phasen/Streams, verknüpfte Übungs-IDs | `unit_*`, `sections_summary_*` | +| `framework_program` (geplant) | Ziele pro Woche/Schicht, Slots, bereits geplante Einheiten, Skill-Scores | `framework_*`, `slot_*`, aggregierte KPIs | + +**Regel:** Planungs-UI baut keine Prompt-Strings; sie liefert **Domän-DTOs** → Builder erzeugen **Platzhalter-Map + ggf. Anhänge**. + +### 3.3 Skill-Retrieval und Prompts + +`ai_skill_retrieval_profiles` steuert **Katalog‑Zusammenstellung** vor dem Platzhalter `{{skills_catalog}}` — das bleibt **orthogonal** zur Prompt-Verwaltung: Prompt ändert *Anweisung*, Profil ändert *welche Skills im Kontextfenster sind*. + +--- + +## 4. Trainingsplanung & Rahmen — erwartete Komplexität + +Risiken: sehr große Kontexte (viele Slots, Streams, Bibliotheken), wiederholte KI-Anfragen, Token-Limits. + +**Vorbereitende Strategien:** + +1. **Gestufte Kontexte:** Rohdaten → interne Kurzfassungen (optional zweiter Prompt oder heuristisch) → finale Generator-Prompt nur mit komprimierten Summaries. +2. **Slug-Pro-Use-Case:** z. B. `training_unit_trainer_notes`, `framework_slot_coach_hint` — jeweils schmaler Vertrag statt „ein Prompt für alles“. +3. **Output-Verträge:** JSON-Schema + Server-Validierung vor UI; Fehlermeldungen mit Referenz auf Slug/Version. +4. **Feature-Flags / Modell-Overrides** pro Slug (optional in DB oder Env) für Dev/Prod ohne große Codepfade. + +--- + +## 5. Mitai (Jinkendo) + +Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter, Preview), **andere** Kontext-Builder und ggf. andere Mandanten/Overlays. Eine gemeinsame **Resolver-/Mustache-Ebene** ist wünschenswert; **Shinkan-spezifische** Planungs- und Rahmenkontexte bleiben in Shinkan gekapselt. + +--- + +## 6. Betrieb, Sicherheit, Observability + +- **Audit:** `updated_by` / Änderungshistorie für Templates (Backlog), heute: Timestamps. +- **Prompt-Injection:** System-/User-Segmente trennen; sensible Regeln in `system`/`developer`-äquivalenten Blöcken (wenn API das hergibt). +- **Logging:** weiter `SHINKAN_AI_DEBUG`; langfristig Hash/Länge des **aufgelösten** Prompts pro Request (ohne Secrets). +- **Kosten/Latenz:** Timeouts, max. Token-Hinweise pro Slug-Konfiguration. + +--- + +## 7. Phasenplan (empfohlen, ohne Big-Bang) + +```mermaid +flowchart LR + subgraph heute + A[ai_prompts DB] + B[prompt_resolver Mustache] + C[ai_prompt_runtime Loader + ContextKind] + D[exercise_ai] + end + A --> B + A --> C + 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. | +| **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. | + +--- + +## 8. Was bewusst vermieden werden soll + +- Vollständige „Workflow-Engine“ mit beliebigen Graphen, bevor 2–3 konkrete Planungs-Anwendungsfälle live sind. +- Pro-Verein-Prompt-Kopien vor klar definierter Produkt-Anforderung (sonst Daten- und Pflege-Spirale). +- Unbegrenzte `include`-rekursive Textmakros ohne Tiefenschutz. + +--- + +## 9. Querverweise + +- 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` + +--- + +**Version:** 1.0 · **Datum:** 2026-05-30 diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 1937e1d..e16267c 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -33,19 +33,22 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT | | maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT | | matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT | +| matrix_editor | `/api/admin/matrix-editor/*` (Export/Import Editor-Bundle) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale Fähigkeitsmatrix ohne Mandantenkontext | | import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT | | ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug | +| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext | **Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. -Letzte Änderung: 2026-05-29 — Superadmin-CRUD `/api/admin/ai-skill-retrieval-profiles*` dokumentiert; `POST /api/exercises/ai/suggest` mit optionalem `focus_areas_context` (Migration 068). +Letzte Änderung: 2026-05-30 — Superadmin `/api/admin/ai-prompts*` (Prompt-Pflege, Vorschau ohne OpenRouter); weiterhin suggest + Retrieval-Profile. --- ### Changelog (Fortführung) +- **2026-05-30:** Superadmin-API `ai_prompts_admin` (`/api/admin/ai-prompts*`) dokumentiert. - **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert. - **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert. diff --git a/CLAUDE.md b/CLAUDE.md index ec43ca3..ebea17c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ > | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** | > | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** | > | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** | +> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` | ## Projekt-Übersicht diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py new file mode 100644 index 0000000..4ca8560 --- /dev/null +++ b/backend/ai_prompt_runtime.py @@ -0,0 +1,74 @@ +""" +Gemeinsame KI-Prompt-Laufzeit (Shinkan): DB-Lesezugriff ai_prompts + Kontext-Arten. + +Bleibt ohne Import von exercise_ai (kein Zirkel). Domänen wie exercise_ai nutzen +load_ai_prompt_row und die Enum; Platzhalter bauen sie selbst oder über geteilte Builder. +""" +from __future__ import annotations + +from enum import Enum +from typing import Any, Dict, Optional + +_EXERCISE_AI_SLUGS = frozenset( + { + "exercise_summary", + "exercise_skill_suggestions", + } +) + + +class AiPromptContextKind(str, Enum): + """ + Logischer Kontext fuer Platzhalter/Builder — erweiterbar fuer Planung/Rahmen + ohne bestehende Slugs zu invalidieren. + """ + + EXERCISE_FORM_AI = "exercise_form_ai" + + +def context_kind_for_slug(slug: str) -> Optional[AiPromptContextKind]: + """Ordnet einen DB-Slug einer Kontext-Art zu, sofern registriert.""" + s = (slug or "").strip().lower() + if s in _EXERCISE_AI_SLUGS: + return AiPromptContextKind.EXERCISE_FORM_AI + return None + + +def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[Dict[str, Any]]: + """ + Laedt eine Zeile ai_prompts fuer Laufzeit-Orchestrierung. + + active_only=True: inaktive Prompts werden wie fehlend behandelt (503 im Aufrufer). + """ + if active_only: + cur.execute( + """ + SELECT slug, display_name, template, output_format, active + FROM ai_prompts + WHERE slug = %s AND active = true + """, + (slug,), + ) + else: + cur.execute( + """ + SELECT slug, display_name, template, output_format, active + FROM ai_prompts + WHERE slug = %s + """, + (slug,), + ) + row = cur.fetchone() + if not row: + return None + d = dict(row) + if active_only and not d.get("active", True): + return None + return d + + +__all__ = [ + "AiPromptContextKind", + "context_kind_for_slug", + "load_ai_prompt_row", +] diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py index bf7d714..6391740 100644 --- a/backend/exercise_ai.py +++ b/backend/exercise_ai.py @@ -18,6 +18,9 @@ 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 + _LOGGER = logging.getLogger("shinkan.exercise_ai") @@ -488,30 +491,46 @@ def build_contextual_skills_catalog_block( return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)" -def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]: - cur.execute( - """ - SELECT slug, display_name, template, output_format, active - FROM ai_prompts - WHERE slug = %s - """, - (slug,), - ) - row = cur.fetchone() - if not row: - return None - d = dict(row) - if not d.get("active", True): - return None - return d - - -def _render_template(template: str, ctx: Dict[str, str]) -> str: - out = template or "" - for key, val in ctx.items(): - placeholder = "{{" + key + "}}" - out = out.replace(placeholder, val if val is not None else "") - return out +def build_exercise_placeholder_variables( + cur, + *, + slug: str, + title: Optional[str], + goal: Optional[str], + execution: Optional[str], + focus_area_hint: Optional[str], + focus_areas_context: Optional[Sequence[Tuple[int, bool]]], +) -> Dict[str, str]: + """ + Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI. + """ + s = (slug or "").strip().lower() + if s == "pipeline": + return {} + g_plain = strip_html_to_plain(goal) + e_plain = strip_html_to_plain(execution) + t_title = (title or "").strip() + focus = (focus_area_hint or "").strip() + ctx: Dict[str, str] = { + "exercise_title": t_title or "-", + "exercise_focus_area": focus or "-", + "exercise_goal": g_plain or "-", + "exercise_execution": e_plain or "-", + } + if s == "exercise_summary": + return ctx + if s == "exercise_skill_suggestions": + catalog = build_contextual_skills_catalog_block( + cur, + title=t_title, + goal_plain=g_plain, + execution_plain=e_plain, + focus_hint=focus or None, + focus_ctx=focus_areas_context, + ) + ctx["skills_catalog"] = catalog + return ctx + raise ValueError(f"Kein Platzhalter-Kontext fuer slug={slug!r} definiert.") def _first_balanced_json_array(text: str) -> Optional[str]: @@ -696,21 +715,28 @@ def run_exercise_ai_suggestion( ) if want_summary: - prow = _load_prompt_row(cur, "exercise_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.") - ctx = { - "exercise_title": t_title or "-", - "exercise_focus_area": focus or "-", - "exercise_goal": g_plain or "-", - "exercise_execution": e_plain or "-", - } - prompt = _render_template(str(prow["template"]), ctx) + try: + ctx = build_exercise_placeholder_variables( + cur, + slug="exercise_summary", + title=title, + goal=goal, + execution=execution, + focus_area_hint=focus_area_hint, + focus_areas_context=focus_areas_context, + ) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + rendered = render_mustache_template(str(prow["template"]), ctx) + prompt = rendered.text if _ai_debug_on(): _LOGGER.warning( - "AI_DEBUG exercise_ai summary prompt_slug=exercise_summary prompt_chars=%s unreplaced_mustache_pairs=%s", + "AI_DEBUG exercise_ai summary prompt_slug=exercise_summary prompt_chars=%s placeholders_remaining=%s", len(prompt), - prompt.count("{{"), + len(rendered.placeholders_remaining), ) try: raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt) @@ -729,33 +755,31 @@ def run_exercise_ai_suggestion( result["summary"] = {"text": text, "ai_generated": True, "model": model} if want_skills: - srow = _load_prompt_row(cur, "exercise_skill_suggestions") + 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.", ) - catalog = build_contextual_skills_catalog_block( - cur, - title=t_title, - goal_plain=g_plain, - execution_plain=e_plain, - focus_hint=focus or None, - focus_ctx=focus_areas_context, - ) - ctx = { - "exercise_title": t_title or "-", - "exercise_focus_area": focus or "-", - "exercise_goal": g_plain or "-", - "exercise_execution": e_plain or "-", - "skills_catalog": catalog, - } - prompt = _render_template(str(srow["template"]), ctx) + try: + ctx = build_exercise_placeholder_variables( + cur, + slug="exercise_skill_suggestions", + title=title, + goal=goal, + execution=execution, + focus_area_hint=focus_area_hint, + focus_areas_context=focus_areas_context, + ) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + rendered = render_mustache_template(str(srow["template"]), ctx) + prompt = rendered.text if _ai_debug_on(): _LOGGER.warning( "AI_DEBUG exercise_ai skills prompt_slug=exercise_skill_suggestions catalog_chars=%s prompt_chars=%s " "template_has_skills_placeholder=%s", - len(catalog), + len(ctx.get("skills_catalog") or ""), len(prompt), "{{skills_catalog}}" in str(srow.get("template") or ""), ) @@ -808,6 +832,7 @@ def run_exercise_ai_suggestion( __all__ = [ "build_contextual_skills_catalog_block", + "build_exercise_placeholder_variables", "run_exercise_ai_suggestion", "strip_html_to_plain", ] diff --git a/backend/main.py b/backend/main.py index 32e5519..fbe10cf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -193,7 +193,7 @@ def read_root(): return out # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_skill_retrieval_admin +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin app.include_router(auth.router) app.include_router(profiles.router) @@ -216,10 +216,12 @@ app.include_router(training_framework_programs.router) app.include_router(catalogs.router) app.include_router(maturity_models.router) app.include_router(matrix_stack_bundle.router) +app.include_router(matrix_editor.router) app.include_router(import_wiki.router) app.include_router(import_wiki_admin.router) app.include_router(legal_documents.router) app.include_router(content_reports.router) +app.include_router(ai_prompts_admin.router) app.include_router(ai_skill_retrieval_admin.router) # Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad diff --git a/backend/migrations/069_ai_prompts_default_template.sql b/backend/migrations/069_ai_prompts_default_template.sql new file mode 100644 index 0000000..bb91511 --- /dev/null +++ b/backend/migrations/069_ai_prompts_default_template.sql @@ -0,0 +1,10 @@ +-- Migration 069: ai_prompts default_template fuer Ruecksetzen & Transparenz +-- Setzt fuer bestehende System-Prompt-Zeilen default_template aus dem aktuellen template, +-- sofern noch kein Referenzinhalt gespeichert war (Migration 067 hatte NULL fuer exercise_*). + +UPDATE ai_prompts +SET default_template = template, + updated_at = NOW() +WHERE default_template IS NULL + AND template IS NOT NULL + AND LENGTH(TRIM(template)) > 0; diff --git a/backend/prompt_resolver.py b/backend/prompt_resolver.py new file mode 100644 index 0000000..191d16c --- /dev/null +++ b/backend/prompt_resolver.py @@ -0,0 +1,128 @@ +""" +Mustache-aehnliche Platzhalter {{schluessel}} fuer KI-Templates aus ai_prompts. + +Kein Vereinsbezug — reine Textersetzung; Aufrufe aus exercise_ai und Admin-Vorschau. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Dict, List, Mapping + +_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}") + + +def _placeholder_pattern_for_key(key: str) -> re.Pattern[str]: + return re.compile(r"\{\{\s*" + re.escape(str(key).strip()) + r"\s*\}\}") + + +@dataclass(frozen=True) +class MustacheRenderResult: + """Ergebnis von render_mustache_template.""" + + text: str + keys_in_template: List[str] + keys_substituted: List[str] + keys_missing_variables: List[str] + placeholders_remaining: List[str] + + +def extract_mustache_keys(template: str) -> List[str]: + """Platzhalter-Namen in Vorkommensreihenfolge, ohne erstes Duplikat.""" + seen: set[str] = set() + ordered: List[str] = [] + for m in _PLACEHOLDER_RE.finditer(template or ""): + k = str(m.group(1) or "").strip() + if not k or k in seen: + continue + seen.add(k) + ordered.append(k) + return ordered + + +def render_mustache_template(template: str, variables: Mapping[str, str]) -> MustacheRenderResult: + """ + Ersetzt {{keys}} durch die passenden Strings. + Variablen, die fuer einen im Template genutzten Key fehlen, werden als Leerstring ersetzt. + + Rueckgabe-liste keys_missing_variables: Keys, die im Template vorkommen, aber nicht als Map-Keys + uebergeben wurden (oder None-Wert entsprachen Leerung). + """ + tpl_in = template or "" + vars_norm: Dict[str, str] = {} + for k, v in variables.items(): + vars_norm[str(k)] = "" if v is None else str(v) + keys_in = extract_mustache_keys(tpl_in) + missing_known: List[str] = [] + out = tpl_in + substituted: List[str] = [] + + for key in keys_in: + pat = _placeholder_pattern_for_key(key) + repl = vars_norm.get(key) + if key not in vars_norm: + missing_known.append(key) + repl = "" + substituted.append(key) + out = pat.sub(repl, out) + + still = extract_mustache_keys(out) + + return MustacheRenderResult( + text=out, + keys_in_template=keys_in, + keys_substituted=substituted, + keys_missing_variables=missing_known, + placeholders_remaining=still, + ) + + +def exercise_placeholder_catalog() -> dict: + """ + Statischer Platzhalter-Katalog fuer Uebungs-KI-Templates — deckt aktuelle Seeds ab. + (Erweiterung andere Kontexte: matrix/import folgen separat.) + """ + defs = [ + { + "key": "exercise_title", + "placeholder": "{{exercise_title}}", + "description": "Titel der Uebung (oder Platzhalter, wenn leer).", + "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], + }, + { + "key": "exercise_focus_area", + "placeholder": "{{exercise_focus_area}}", + "description": "Fokuskontext (Text-Hinweis aus Formular, optional).", + "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], + }, + { + "key": "exercise_goal", + "placeholder": "{{exercise_goal}}", + "description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.", + "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], + }, + { + "key": "exercise_execution", + "placeholder": "{{exercise_execution}}", + "description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.", + "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], + }, + { + "key": "skills_catalog", + "placeholder": "{{skills_catalog}}", + "description": ( + "Gewichtete, kontextbezogene Liste aus dem Skill-Katalog (retrieval_profiles). " + "Nur fuer exercise_skill_suggestions." + ), + "used_by_slugs": ["exercise_skill_suggestions"], + }, + ] + return {"context": "exercise", "placeholders": defs} + + +__all__ = [ + "MustacheRenderResult", + "exercise_placeholder_catalog", + "extract_mustache_keys", + "render_mustache_template", +] diff --git a/backend/routers/ai_prompts_admin.py b/backend/routers/ai_prompts_admin.py new file mode 100644 index 0000000..b0bb55d --- /dev/null +++ b/backend/routers/ai_prompts_admin.py @@ -0,0 +1,253 @@ +""" +Superadmin-API: Verwaltung von ai_prompts (Templates, Aktivierung, Vorschau). + +Kein Vereinsbezug — require_auth + is_superadmin; kein TenantContext. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from auth import require_auth +from club_tenancy import is_superadmin +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 + +router = APIRouter(tags=["admin_ai_prompts"]) + + +def _require_superadmin(session: dict = Depends(require_auth)) -> dict: + role = (session.get("role") or "").strip().lower() + if not is_superadmin(role): + raise HTTPException(status_code=403, detail="Nur Superadmins") + return session + + +def _prompts_table_ready(cur) -> bool: + cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_prompts",)) + row = cur.fetchone() + if not row: + return False + val = row.get("t") if isinstance(row, dict) else row[0] + return val is not None and str(val).strip() != "" + + +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, + active, sort_order, created_at, updated_at + FROM ai_prompts WHERE id = %s + """, + (prompt_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Prompt nicht gefunden") + return dict(row) + + +class AiPromptUpdateBody(BaseModel): + template: Optional[str] = None + active: Optional[bool] = None + display_name: Optional[str] = Field(None, max_length=200) + description: Optional[str] = Field(None, max_length=8000) + + +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 + + +@router.get("/api/admin/ai-prompts/catalog/placeholders") +def get_ai_prompt_placeholders_catalog(session: dict = Depends(_require_superadmin)): + return exercise_placeholder_catalog() + + +@router.get("/api/admin/ai-prompts") +def list_ai_prompts(session: dict = Depends(_require_superadmin)): + with get_db() as conn: + cur = get_cursor(conn) + if not _prompts_table_ready(cur): + raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.") + cur.execute( + """ + SELECT id, slug, display_name, description, category, output_format, active, + sort_order, is_system_default, default_template + FROM ai_prompts + ORDER BY sort_order ASC NULLS LAST, id ASC + """ + ) + rows = [r2d(r) if not isinstance(r, dict) else r for r in cur.fetchall()] + out = [] + for r in rows: + dt = (r.get("default_template") or "").strip() + tmpl = (r.get("template") or "").strip() + is_modified = bool(dt and tmpl != dt) + rr = {k: v for k, v in r.items() if k != "default_template"} + rr["is_modified"] = is_modified if dt else False + rr["has_reference_template"] = bool(dt) + out.append(rr) + return out + + +@router.get("/api/admin/ai-prompts/{prompt_id:int}") +def get_ai_prompt(prompt_id: int, session: dict = Depends(_require_superadmin)): + with get_db() as conn: + cur = get_cursor(conn) + if not _prompts_table_ready(cur): + raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.") + row = _fetch_prompt_any(cur, prompt_id) + dt = (row.get("default_template") or "").strip() + tmpl = (row.get("template") or "").strip() + row_out = dict(row) + row_out["is_modified"] = bool(dt and tmpl != dt) if dt else False + row_out["has_reference_template"] = bool(dt) + return row_out + + +@router.put("/api/admin/ai-prompts/{prompt_id:int}") +def update_ai_prompt( + prompt_id: int, + body: AiPromptUpdateBody, + session: dict = Depends(_require_superadmin), +): + with get_db() as conn: + cur = get_cursor(conn) + if not _prompts_table_ready(cur): + raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.") + old = _fetch_prompt_any(cur, prompt_id) + + next_template = old["template"] + if body.template is not None: + tpl = body.template.strip() if isinstance(body.template, str) else "" + if not tpl: + raise HTTPException(status_code=400, detail="template darf nicht leer sein.") + next_template = tpl + + next_active = bool(old.get("active", True)) + if body.active is not None: + next_active = body.active + + next_name = body.display_name if body.display_name is not None else old.get("display_name") or "" + next_name = (next_name or "").strip() + if not next_name: + raise HTTPException(status_code=400, detail="display_name darf nicht leer sein.") + + next_desc = body.description if body.description is not None else old.get("description") or "" + next_desc = (next_desc or "").strip() + + cur.execute( + """ + UPDATE ai_prompts + SET template = %s, active = %s, display_name = %s, description = %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, + created_at, updated_at + """, + (next_template, next_active, next_name, next_desc, prompt_id), + ) + row = dict(cur.fetchone()) + conn.commit() + dt = (row.get("default_template") or "").strip() + tmpl = (row.get("template") or "").strip() + row["is_modified"] = bool(dt and tmpl != dt) if dt else False + return row + + +@router.post("/api/admin/ai-prompts/{prompt_id:int}/reset-template") +def reset_ai_prompt_template(prompt_id: int, session: dict = Depends(_require_superadmin)): + with get_db() as conn: + cur = get_cursor(conn) + if not _prompts_table_ready(cur): + raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.") + old = _fetch_prompt_any(cur, prompt_id) + dt_old = old.get("default_template") + if dt_old is None or not str(dt_old).strip(): + raise HTTPException( + status_code=400, + detail="Kein gespeicherter Referenztext (default_template) — Ruecksetzen nicht möglich.", + ) + cur.execute( + """ + UPDATE ai_prompts + 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, + created_at, updated_at + """, + (prompt_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Prompt nicht gefunden") + rr = dict(row) + conn.commit() + tmpl = (rr.get("template") or "").strip() + dt = (rr.get("default_template") or "").strip() + rr["is_modified"] = bool(dt and tmpl != dt) if dt else False + return rr + + +@router.post("/api/admin/ai-prompts/{prompt_id:int}/preview") +def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict = Depends(_require_superadmin)): + with get_db() as conn: + cur = get_cursor(conn) + if not _prompts_table_ready(cur): + 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, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + elif slug == "pipeline": + vars_map = {} + warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau." + else: + 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) + return { + "slug": slug, + "resolved_template": rendered.text, + "keys_in_template": rendered.keys_in_template, + "keys_missing_variables": rendered.keys_missing_variables, + "placeholders_remaining": rendered.placeholders_remaining, + "warning": warn, + } diff --git a/backend/routers/matrix_editor.py b/backend/routers/matrix_editor.py new file mode 100644 index 0000000..40e203f --- /dev/null +++ b/backend/routers/matrix_editor.py @@ -0,0 +1,727 @@ +""" +Superadmin Export/Import für zentrale Pflege der Fähigkeitsmatrix. + +Fokus: Beschreibungen und Gewichtungen (skills.importance, model_skills.relevance, +skill_level_definitions, model_skill_levels) — flaches, bearbeitbares Format. + +Kein Vereinsbezug — require_auth + is_superadmin; kein TenantContext. +# ACCESS_LAYER exempt: Plattform-Superadmin-Tool; globale Fähigkeitsmatrix ohne Mandantenkontext +""" +from __future__ import annotations + +import csv +import io +import re +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse, PlainTextResponse + +from auth import require_auth +from club_tenancy import is_superadmin +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api/admin/matrix-editor", tags=["admin_matrix_editor"]) + +KIND_V1 = "shinkan.matrix_editor.v1" + +SKILLS_CSV_FIELDS = [ + "skill_key", + "main_category", + "subcategory", + "skill_name", + "description", + "importance", + "karate_relevance", + "relevance_level", + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", +] + +MATRIX_CSV_FIELDS = [ + "model_key", + "model_name", + "skill_key", + "skill_name", + "relevance", + "sort_order", + "level_number", + "level_label", + "description", + "observable_criteria", +] + + +def _require_superadmin(session: dict) -> dict: + role = (session.get("role") or "").strip().lower() + if not is_superadmin(role): + raise HTTPException(status_code=403, detail="Nur Superadmins") + return session + + +def _slugify_label(text: str) -> str: + t = (text or "").strip().lower() + t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE) + t = re.sub(r"_+", "_", t).strip("_") + return t[:48] or "gruppe" + + +def _skill_key(main_slug: Optional[str], cat_slug: Optional[str], name: str) -> str: + return f"{main_slug or ''}|{cat_slug or ''}|{(name or '').strip()}" + + +def _model_key(import_id: Optional[str], name: str) -> str: + iid = (import_id or "").strip() + if iid: + return iid + return _slugify_label(name) + + +def _parse_skill_key(raw: str) -> Tuple[str, str, str]: + parts = (raw or "").split("|", 2) + while len(parts) < 3: + parts.append("") + return parts[0], parts[1], parts[2] + + +def _csv_response(text: str, filename: str) -> PlainTextResponse: + return PlainTextResponse( + content=text, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +def _rows_to_csv(fieldnames: List[str], rows: List[Dict[str, Any]]) -> str: + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore", lineterminator="\n") + writer.writeheader() + for row in rows: + writer.writerow({k: row.get(k, "") for k in fieldnames}) + return buf.getvalue() + + +def _load_editor_payload(cur) -> Dict[str, Any]: + cur.execute( + """ + SELECT s.id, s.name, s.description, s.importance, s.karate_relevance, s.relevance_level, + mc.slug AS main_category_slug, mc.name AS main_category_name, + sc.slug AS category_slug, sc.name AS category_name + FROM skills s + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id + LEFT JOIN skill_categories sc ON s.category_id = sc.id + ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.sort_order NULLS LAST, s.name + """ + ) + skill_rows = [r2d(r) for r in cur.fetchall()] + skill_ids = [int(r["id"]) for r in skill_rows] + + level_defs: Dict[int, Dict[int, str]] = {} + if skill_ids: + cur.execute( + """ + SELECT skill_id, level, description + FROM skill_level_definitions + WHERE skill_id = ANY(%s) + ORDER BY skill_id, level + """, + (skill_ids,), + ) + for r in cur.fetchall(): + sid = int(r["skill_id"]) + level_defs.setdefault(sid, {})[int(r["level"])] = r.get("description") or "" + + skills_out: List[Dict[str, Any]] = [] + skills_csv: List[Dict[str, Any]] = [] + for s in skill_rows: + sid = int(s["id"]) + main_slug = s.get("main_category_slug") + cat_slug = s.get("category_slug") + name = (s.get("name") or "").strip() + key = _skill_key(main_slug, cat_slug, name) + defs = level_defs.get(sid, {}) + level_map = {str(i): defs.get(i, "") for i in range(1, 6)} + entry = { + "skill_key": key, + "main_category": s.get("main_category_name") or "", + "subcategory": s.get("category_name") or "", + "skill_name": name, + "description": s.get("description") or "", + "importance": s.get("importance"), + "karate_relevance": s.get("karate_relevance"), + "relevance_level": s.get("relevance_level"), + "level_definitions": level_map, + } + skills_out.append(entry) + csv_row = { + "skill_key": key, + "main_category": entry["main_category"], + "subcategory": entry["subcategory"], + "skill_name": name, + "description": entry["description"], + "importance": entry["importance"] if entry["importance"] is not None else "", + "karate_relevance": entry["karate_relevance"] or "", + "relevance_level": entry["relevance_level"] if entry["relevance_level"] is not None else "", + } + for i in range(1, 6): + csv_row[f"level_{i}"] = level_map.get(str(i), "") + skills_csv.append(csv_row) + + cur.execute("SELECT id, name, import_id, level_count, status FROM maturity_models ORDER BY name") + models_raw = [r2d(r) for r in cur.fetchall()] + + models_out: List[Dict[str, Any]] = [] + matrix_csv: List[Dict[str, Any]] = [] + for m in models_raw: + mid = int(m["id"]) + mkey = _model_key(m.get("import_id"), m.get("name") or "") + mname = (m.get("name") or "").strip() + + cur.execute( + """ + SELECT level_number, name, description + FROM model_levels + WHERE maturity_model_id = %s + ORDER BY sort_order ASC, level_number ASC + """, + (mid,), + ) + levels = [r2d(r) for r in cur.fetchall()] + level_label = {int(lv["level_number"]): lv.get("name") or "" for lv in levels} + + cur.execute( + """ + SELECT ms.skill_id, ms.sort_order, ms.relevance, s.name AS skill_name, + mc.slug AS main_category_slug, sc.slug AS category_slug + FROM model_skills ms + JOIN skills s ON s.id = ms.skill_id + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id + LEFT JOIN skill_categories sc ON s.category_id = sc.id + WHERE ms.maturity_model_id = %s + ORDER BY ms.sort_order ASC, ms.id ASC + """, + (mid,), + ) + model_skills = [r2d(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT msl.skill_id, msl.level_number, msl.description, msl.observable_criteria, + s.name AS skill_name, mc.slug AS main_category_slug, sc.slug AS category_slug + FROM model_skill_levels msl + JOIN skills s ON s.id = msl.skill_id + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id + LEFT JOIN skill_categories sc ON s.category_id = sc.id + WHERE msl.maturity_model_id = %s + ORDER BY msl.skill_id, msl.level_number + """, + (mid,), + ) + skill_levels = [r2d(r) for r in cur.fetchall()] + + ms_by_skill: Dict[int, Dict[str, Any]] = {int(ms["skill_id"]): ms for ms in model_skills} + cells_by_skill: Dict[int, List[Dict[str, Any]]] = {} + for sl in skill_levels: + sid = int(sl["skill_id"]) + cells_by_skill.setdefault(sid, []).append( + { + "level_number": int(sl["level_number"]), + "description": sl.get("description") or "", + "observable_criteria": sl.get("observable_criteria"), + } + ) + + matrix_rows: List[Dict[str, Any]] = [] + seen_skills = set() + for ms in model_skills: + sid = int(ms["skill_id"]) + seen_skills.add(sid) + skey = _skill_key(ms.get("main_category_slug"), ms.get("category_slug"), ms.get("skill_name") or "") + matrix_rows.append( + { + "skill_key": skey, + "skill_name": ms.get("skill_name") or "", + "relevance": ms.get("relevance"), + "sort_order": int(ms.get("sort_order") or 0), + "cells": cells_by_skill.get(sid, []), + } + ) + for cell in cells_by_skill.get(sid, []): + ln = int(cell["level_number"]) + matrix_csv.append( + { + "model_key": mkey, + "model_name": mname, + "skill_key": skey, + "skill_name": ms.get("skill_name") or "", + "relevance": ms.get("relevance") or "", + "sort_order": int(ms.get("sort_order") or 0), + "level_number": ln, + "level_label": level_label.get(ln, ""), + "description": cell.get("description") or "", + "observable_criteria": cell.get("observable_criteria") or "", + } + ) + + for sid, cells in cells_by_skill.items(): + if sid in seen_skills: + continue + sample = cells[0] if cells else {} + cur.execute( + """ + SELECT s.name, mc.slug AS main_category_slug, sc.slug AS category_slug + FROM skills s + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id + LEFT JOIN skill_categories sc ON s.category_id = sc.id + WHERE s.id = %s + """, + (sid,), + ) + sk = r2d(cur.fetchone()) or {} + skey = _skill_key(sk.get("main_category_slug"), sk.get("category_slug"), sk.get("name") or "") + matrix_rows.append( + { + "skill_key": skey, + "skill_name": sk.get("name") or "", + "relevance": None, + "sort_order": 0, + "cells": cells, + } + ) + for cell in cells: + ln = int(cell["level_number"]) + matrix_csv.append( + { + "model_key": mkey, + "model_name": mname, + "skill_key": skey, + "skill_name": sk.get("name") or "", + "relevance": "", + "sort_order": 0, + "level_number": ln, + "level_label": level_label.get(ln, ""), + "description": cell.get("description") or "", + "observable_criteria": cell.get("observable_criteria") or "", + } + ) + + models_out.append( + { + "model_key": mkey, + "model_name": mname, + "level_count": int(m.get("level_count") or 5), + "status": m.get("status"), + "level_labels": [ + { + "level_number": int(lv["level_number"]), + "name": lv.get("name") or "", + "description": lv.get("description"), + } + for lv in levels + ], + "matrix_rows": matrix_rows, + } + ) + + return { + "skills": skills_out, + "skills_csv_rows": skills_csv, + "maturity_models": models_out, + "matrix_csv_rows": matrix_csv, + } + + +def _build_skill_lookup(cur) -> Dict[str, int]: + cur.execute( + """ + SELECT s.id, s.name, mc.slug AS main_category_slug, sc.slug AS category_slug + FROM skills s + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id + LEFT JOIN skill_categories sc ON s.category_id = sc.id + """ + ) + out: Dict[str, int] = {} + for r in cur.fetchall(): + row = r2d(r) + key = _skill_key(row.get("main_category_slug"), row.get("category_slug"), row.get("name") or "") + out[key] = int(row["id"]) + return out + + +def _build_model_lookup(cur) -> Dict[str, int]: + cur.execute("SELECT id, name, import_id FROM maturity_models") + out: Dict[str, int] = {} + for r in cur.fetchall(): + row = r2d(r) + key = _model_key(row.get("import_id"), row.get("name") or "") + out[key] = int(row["id"]) + return out + + +def _resolve_skill_id( + cur, + skill_lookup: Dict[str, int], + *, + skill_key: str, + main_category: str = "", + subcategory: str = "", + skill_name: str = "", +) -> Optional[int]: + key = (skill_key or "").strip() + if key and key in skill_lookup: + return skill_lookup[key] + if key: + main_slug, cat_slug, name = _parse_skill_key(key) + if name and _skill_key(main_slug, cat_slug, name) in skill_lookup: + return skill_lookup[_skill_key(main_slug, cat_slug, name)] + name = (skill_name or "").strip() + if not name: + return None + cur.execute( + """ + SELECT s.id + FROM skills s + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id + LEFT JOIN skill_categories sc ON s.category_id = sc.id + WHERE s.name = %s + AND COALESCE(mc.name, '') = COALESCE(%s, '') + AND COALESCE(sc.name, '') = COALESCE(%s, '') + LIMIT 1 + """, + (name, (main_category or "").strip() or None, (subcategory or "").strip() or None), + ) + row = cur.fetchone() + if row: + return int(row["id"]) + cur.execute("SELECT id FROM skills WHERE name = %s LIMIT 1", (name,)) + row = cur.fetchone() + return int(row["id"]) if row else None + + +def _optional_int(val: Any) -> Optional[int]: + if val is None or val == "": + return None + try: + return int(val) + except (TypeError, ValueError): + return None + + +def _apply_skills_import(cur, skills: List[Dict[str, Any]], skill_lookup: Dict[str, int], warnings: List[str]) -> int: + updated = 0 + for row in skills: + sid = _resolve_skill_id( + cur, + skill_lookup, + skill_key=str(row.get("skill_key") or ""), + main_category=str(row.get("main_category") or ""), + subcategory=str(row.get("subcategory") or ""), + skill_name=str(row.get("skill_name") or ""), + ) + if not sid: + warnings.append(f"Skill nicht gefunden: {row.get('skill_key') or row.get('skill_name')}") + continue + + importance = row.get("importance") + if importance == "": + importance = None + relevance_level = row.get("relevance_level") + if relevance_level == "": + relevance_level = None + + cur.execute( + """ + UPDATE skills SET + description = COALESCE(%s, description), + importance = COALESCE(%s, importance), + karate_relevance = COALESCE(%s, karate_relevance), + relevance_level = COALESCE(%s, relevance_level), + updated_at = NOW() + WHERE id = %s + """, + ( + row.get("description"), + importance, + row.get("karate_relevance") if row.get("karate_relevance") != "" else None, + relevance_level, + sid, + ), + ) + updated += 1 + + defs = row.get("level_definitions") + if isinstance(defs, dict): + for lvl_str, desc in defs.items(): + try: + lvl = int(lvl_str) + except (TypeError, ValueError): + continue + if lvl < 1 or lvl > 10: + continue + text = (desc or "").strip() + if not text: + continue + cur.execute( + """ + INSERT INTO skill_level_definitions (skill_id, level, description) + VALUES (%s, %s, %s) + ON CONFLICT (skill_id, level) DO UPDATE SET + description = EXCLUDED.description, + updated_at = NOW() + """, + (sid, lvl, text), + ) + else: + for i in range(1, 6): + col = row.get(f"level_{i}") + if col is None or str(col).strip() == "": + continue + cur.execute( + """ + INSERT INTO skill_level_definitions (skill_id, level, description) + VALUES (%s, %s, %s) + ON CONFLICT (skill_id, level) DO UPDATE SET + description = EXCLUDED.description, + updated_at = NOW() + """, + (sid, i, str(col).strip()), + ) + return updated + + +def _apply_matrix_import( + cur, + models: List[Dict[str, Any]], + model_lookup: Dict[str, int], + skill_lookup: Dict[str, int], + warnings: List[str], +) -> int: + cells_updated = 0 + for model in models: + mkey = str(model.get("model_key") or "").strip() + mid = model_lookup.get(mkey) + if not mid and model.get("model_name"): + mid = model_lookup.get(_model_key(None, str(model.get("model_name")))) + if not mid: + warnings.append(f"Reifegradmodell nicht gefunden: {mkey or model.get('model_name')}") + continue + + rows = model.get("matrix_rows") or [] + if not rows and model.get("cells"): + rows = [model] + + for mrow in rows: + sid = _resolve_skill_id( + cur, + skill_lookup, + skill_key=str(mrow.get("skill_key") or ""), + skill_name=str(mrow.get("skill_name") or ""), + ) + if not sid: + warnings.append(f"Matrix-Zeile: Skill nicht gefunden ({mrow.get('skill_key')})") + continue + + relevance = mrow.get("relevance") + if relevance is not None and str(relevance).strip() != "": + sort_order = _optional_int(mrow.get("sort_order")) or 0 + cur.execute( + """ + INSERT INTO model_skills (maturity_model_id, skill_id, sort_order, relevance) + VALUES (%s, %s, %s, %s) + ON CONFLICT (maturity_model_id, skill_id) DO UPDATE SET + sort_order = EXCLUDED.sort_order, + relevance = EXCLUDED.relevance + """, + (mid, sid, sort_order, str(relevance).strip()), + ) + + cells = mrow.get("cells") or [] + for cell in cells: + ln = _optional_int(cell.get("level_number")) + if ln is None: + continue + desc = (cell.get("description") or "").strip() + if not desc: + continue + cur.execute( + """ + INSERT INTO model_skill_levels ( + maturity_model_id, skill_id, level_number, + description, observable_criteria + ) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (maturity_model_id, skill_id, level_number) + DO UPDATE SET + description = EXCLUDED.description, + observable_criteria = COALESCE(EXCLUDED.observable_criteria, model_skill_levels.observable_criteria), + updated_at = NOW() + """, + ( + mid, + sid, + ln, + desc, + cell.get("observable_criteria") if cell.get("observable_criteria") else None, + ), + ) + cells_updated += 1 + return cells_updated + + +def _parse_csv_text(text: str) -> List[Dict[str, str]]: + reader = csv.DictReader(io.StringIO(text)) + return [dict(row) for row in reader] + + +def _skills_from_csv_rows(rows: List[Dict[str, str]]) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for row in rows: + if not any(str(v).strip() for v in row.values()): + continue + entry: Dict[str, Any] = { + "skill_key": row.get("skill_key", ""), + "main_category": row.get("main_category", ""), + "subcategory": row.get("subcategory", ""), + "skill_name": row.get("skill_name", ""), + "description": row.get("description", ""), + "importance": _optional_int(row.get("importance")), + "karate_relevance": row.get("karate_relevance", ""), + "relevance_level": _optional_int(row.get("relevance_level")), + } + for i in range(1, 6): + entry[f"level_{i}"] = row.get(f"level_{i}", "") + out.append(entry) + return out + + +def _matrix_from_csv_rows(rows: List[Dict[str, str]]) -> List[Dict[str, Any]]: + by_model: Dict[str, Dict[str, Any]] = {} + row_index: Dict[Tuple[str, str], Dict[str, Any]] = {} + + for row in rows: + if not any(str(v).strip() for v in row.values()): + continue + mkey = (row.get("model_key") or "").strip() + if not mkey: + continue + model = by_model.setdefault( + mkey, + {"model_key": mkey, "model_name": row.get("model_name") or "", "matrix_rows": []}, + ) + skey = (row.get("skill_key") or "").strip() + row_key = (mkey, skey or (row.get("skill_name") or "").strip()) + mrow = row_index.get(row_key) + if not mrow: + mrow = { + "skill_key": skey, + "skill_name": row.get("skill_name") or "", + "relevance": row.get("relevance") or "", + "sort_order": _optional_int(row.get("sort_order")) or 0, + "cells": [], + } + row_index[row_key] = mrow + model["matrix_rows"].append(mrow) + + ln = _optional_int(row.get("level_number")) + if ln is None: + continue + desc = (row.get("description") or "").strip() + if not desc: + continue + mrow["cells"].append( + { + "level_number": ln, + "description": desc, + "observable_criteria": row.get("observable_criteria") or "", + } + ) + + return list(by_model.values()) + + +@router.get("/export") +def export_matrix_editor( + format: str = Query("json", pattern="^(json|csv_skills|csv_matrix)$"), + session: dict = Depends(require_auth), +): + _require_superadmin(session) + export_uid = str(uuid.uuid4()) + with get_db() as conn: + cur = get_cursor(conn) + payload = _load_editor_payload(cur) + + if format == "csv_skills": + csv_text = _rows_to_csv(SKILLS_CSV_FIELDS, payload["skills_csv_rows"]) + return _csv_response(csv_text, f"faehigkeiten-katalog-{export_uid[:8]}.csv") + + if format == "csv_matrix": + csv_text = _rows_to_csv(MATRIX_CSV_FIELDS, payload["matrix_csv_rows"]) + return _csv_response(csv_text, f"faehigkeitsmatrix-zellen-{export_uid[:8]}.csv") + + bundle = { + "kind": KIND_V1, + "export_version": 1, + "bundle_export_id": export_uid, + "exported_at": datetime.now(timezone.utc).isoformat(), + "skills": payload["skills"], + "maturity_models": payload["maturity_models"], + } + return JSONResponse( + content=jsonable_encoder(bundle), + headers={ + "Content-Disposition": f'attachment; filename="matrix-editor-{export_uid[:8]}.json"' + }, + ) + + +@router.post("/import") +def import_matrix_editor( + data: Dict[str, Any] = Body(...), + session: dict = Depends(require_auth), +): + _require_superadmin(session) + warnings: List[str] = [] + skills: List[Dict[str, Any]] = [] + models: List[Dict[str, Any]] = [] + + kind = data.get("kind") + if kind == KIND_V1: + skills = list(data.get("skills") or []) + models = list(data.get("maturity_models") or []) + elif kind == "shinkan.matrix_editor.csv": + part = (data.get("part") or "").strip().lower() + csv_text = data.get("csv_text") or "" + if not csv_text.strip(): + raise HTTPException(400, "csv_text fehlt") + rows = _parse_csv_text(csv_text) + if part == "skills": + skills = _skills_from_csv_rows(rows) + elif part == "matrix": + models = _matrix_from_csv_rows(rows) + else: + raise HTTPException(400, "part muss skills oder matrix sein") + else: + raise HTTPException(400, f"Unbekanntes Format (kind={kind!r})") + + if not skills and not models: + raise HTTPException(400, "Keine importierbaren Daten") + + with get_db() as conn: + cur = get_cursor(conn) + skill_lookup = _build_skill_lookup(cur) + model_lookup = _build_model_lookup(cur) + skills_updated = _apply_skills_import(cur, skills, skill_lookup, warnings) if skills else 0 + cells_updated = _apply_matrix_import(cur, models, model_lookup, skill_lookup, warnings) if models else 0 + + return { + "ok": True, + "skills_updated": skills_updated, + "matrix_cells_updated": cells_updated, + "warnings": warnings, + } diff --git a/backend/scripts/check_access_layer_hints.py b/backend/scripts/check_access_layer_hints.py index acecdc2..9c40fe4 100644 --- a/backend/scripts/check_access_layer_hints.py +++ b/backend/scripts/check_access_layer_hints.py @@ -24,10 +24,12 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset( "platform_media_storage.py", "legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin() "ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant + "ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant "catalogs.py", "skills.py", "maturity_models.py", "matrix_stack_bundle.py", + "matrix_editor.py", # Superadmin Editor-Export/Import Fähigkeitsmatrix; require_auth + is_superadmin — kein Vereinsmandant "import_wiki.py", "import_wiki_admin.py", } diff --git a/backend/tests/test_matrix_editor.py b/backend/tests/test_matrix_editor.py new file mode 100644 index 0000000..5a3ea18 --- /dev/null +++ b/backend/tests/test_matrix_editor.py @@ -0,0 +1,146 @@ +"""GET/POST /api/admin/matrix-editor — Superadmin Export/Import.""" +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("SKIP_DB_MIGRATE", "1") + +from auth import require_auth +from main import app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _clear_overrides(): + yield + app.dependency_overrides.pop(require_auth, None) + + +def test_matrix_editor_export_requires_superadmin(client: TestClient) -> None: + def _admin(): + return {"profile_id": 1, "role": "admin"} + + app.dependency_overrides[require_auth] = _admin + r = client.get("/api/admin/matrix-editor/export", headers={"X-Auth-Token": "t"}) + assert r.status_code == 403 + + +@patch("routers.matrix_editor._load_editor_payload") +def test_matrix_editor_export_json_ok(mock_load, client: TestClient) -> None: + def _superadmin(): + return {"profile_id": 1, "role": "superadmin"} + + app.dependency_overrides[require_auth] = _superadmin + mock_load.return_value = { + "skills": [{"skill_key": "a|b|Test", "skill_name": "Test", "description": "x", "level_definitions": {}}], + "skills_csv_rows": [], + "maturity_models": [], + "matrix_csv_rows": [], + } + + mock_cm = MagicMock() + mock_cm.__enter__.return_value = MagicMock() + mock_cm.__exit__.return_value = False + + with patch("routers.matrix_editor.get_db", return_value=mock_cm), patch( + "routers.matrix_editor.get_cursor", return_value=MagicMock() + ): + r = client.get("/api/admin/matrix-editor/export", headers={"X-Auth-Token": "t"}) + + assert r.status_code == 200 + body = r.json() + assert body["kind"] == "shinkan.matrix_editor.v1" + assert len(body["skills"]) == 1 + + +@patch("routers.matrix_editor._load_editor_payload") +def test_matrix_editor_export_csv_skills(mock_load, client: TestClient) -> None: + def _superadmin(): + return {"profile_id": 1, "role": "superadmin"} + + app.dependency_overrides[require_auth] = _superadmin + mock_load.return_value = { + "skills": [], + "skills_csv_rows": [ + { + "skill_key": "main|sub|Skill A", + "main_category": "Main", + "subcategory": "Sub", + "skill_name": "Skill A", + "description": "Desc", + "importance": 3, + "karate_relevance": "", + "relevance_level": "", + "level_1": "L1", + "level_2": "", + "level_3": "", + "level_4": "", + "level_5": "", + } + ], + "maturity_models": [], + "matrix_csv_rows": [], + } + + mock_cm = MagicMock() + mock_cm.__enter__.return_value = MagicMock() + mock_cm.__exit__.return_value = False + + with patch("routers.matrix_editor.get_db", return_value=mock_cm), patch( + "routers.matrix_editor.get_cursor", return_value=MagicMock() + ): + r = client.get( + "/api/admin/matrix-editor/export?format=csv_skills", + headers={"X-Auth-Token": "t"}, + ) + + assert r.status_code == 200 + assert "skill_key" in r.text + assert "Skill A" in r.text + + +@patch("routers.matrix_editor._apply_matrix_import", return_value=0) +@patch("routers.matrix_editor._apply_skills_import", return_value=1) +@patch("routers.matrix_editor._build_model_lookup", return_value={}) +@patch("routers.matrix_editor._build_skill_lookup", return_value={"main|sub|Skill A": 42}) +def test_matrix_editor_import_json_ok( + mock_skill_lookup, + mock_model_lookup, + mock_apply_skills, + mock_apply_matrix, + client: TestClient, +) -> None: + def _superadmin(): + return {"profile_id": 1, "role": "superadmin"} + + app.dependency_overrides[require_auth] = _superadmin + + mock_cm = MagicMock() + mock_conn = MagicMock() + mock_cm.__enter__.return_value = mock_conn + mock_cm.__exit__.return_value = False + + with patch("routers.matrix_editor.get_db", return_value=mock_cm), patch( + "routers.matrix_editor.get_cursor", return_value=MagicMock() + ): + r = client.post( + "/api/admin/matrix-editor/import", + headers={"X-Auth-Token": "t", "Content-Type": "application/json"}, + json={ + "kind": "shinkan.matrix_editor.v1", + "skills": [{"skill_key": "main|sub|Skill A", "description": "Neu"}], + }, + ) + + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["skills_updated"] == 1 diff --git a/backend/version.py b/backend/version.py index deb6ea3..3a1f6d6 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.157" -BUILD_DATE = "2026-05-22" -DB_SCHEMA_VERSION = "20260529068" +APP_VERSION = "0.8.159" +BUILD_DATE = "2026-05-30" +DB_SCHEMA_VERSION = "20260530069" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -19,11 +19,13 @@ 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 "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.30.3", # Frontend KI ohne Modal-Grausperre; Anthropic/OpenRouter verschachtelte Textbloecke; SHINKAN_AI_DEBUG Warn-Logs exercise_ai/OpenRouter + "exercises": "2.31.1", # AI nutzt load_ai_prompt_row aus ai_prompt_runtime "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 @@ -38,6 +40,22 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.159", + "date": "2026-05-30", + "changes": [ + "Doku: AI_PROMPT_TARGET_ARCHITECTURE.md (Zielbild Kontext-Arten, Composition, Planung/Rahmen, Phasenplan); HANDOVER & docs/architecture/README verlinkt;", + "Backend ai_prompt_runtime: AiPromptContextKind, load_ai_prompt_row — exercise_ai ohne duplizierte Prompt-SQL;", + ], + }, + { + "version": "0.8.158", + "date": "2026-05-30", + "changes": [ + "KI-Prompts: Backend prompt_resolver ({{platzhalter}} robust), Admin-API /api/admin/ai-prompts* (Liste, Detail, PUT, Preview, Reset), Migration 069 default_template;", + "Superadmin-Web: Admin KI Prompts (/admin/ai-prompts) mit Platzhalter-Katalog und Vorschau ohne OpenRouter.", + ], + }, { "version": "0.8.157", "date": "2026-05-22", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 4eb3217..48abe41 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover -**Stand:** 2026-05-29 -**App-Version / DB-Schema:** App **`0.8.157`** (KI Übungen: UX-Flow + AI_DEBUG Logs), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION` +**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`). 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**. @@ -29,6 +29,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl | Media / Upload-Limits / Embed | `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` | | MediaWiki-Import | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` | | Zugriffsschicht, Mandant, Governance | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | +| KI-Prompt-System — Zielarchitektur (Roadmap) | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` | | Tenant-Endpoints (Audit) | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` | | Rahmenprogramm · Planung | `.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md` | | **Trainingsmodule & Kombinationsübungen (Fachspez, Drift-Schutz)** | `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (§ 10.2.1 Archetyp-IDs, § 10.4 Coaching-Stufen, **Anhang A** Code-Abgleich) | @@ -88,14 +89,16 @@ 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.157**) +### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.159**) -- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; API-Felder **`KI_FEATURES_SPEC.md`** §5.2 -- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus) +- **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 +- **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:** `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 -- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiSkillRetrievalPage.jsx`**, Route **`/admin/ai-skill-retrieval`** +- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiPromptsPage.jsx`** (`/admin/ai-prompts`), **`AdminAiSkillRetrievalPage.jsx`** (`/admin/ai-skill-retrieval`) --- diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 91bed80..9a96ddf 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -13,7 +13,7 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP | [`frontend/src/api/exercises.js`](../../frontend/src/api/exercises.js) | Phase 4: Übungen, Medien/Archiv, Progressionsgraphen, KI-Hilfen | | [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) | | [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) | -| [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) | **Verbindliche** Shinkan-spezifische Regeln (Ergänzung zu den globalen Rules) | +| [KI-Prompt-Zielarchitektur](../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) | Roadmap: Kontext-Arten, Composition, Planung/Rahmen, Phasenplan (verbindliche Zielrichtung) | ## Tests (E2E / Refaktor-Budget) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a5bbff3..43d6c1b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -55,6 +55,7 @@ const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage')) const LegalPage = lazy(() => import('./pages/LegalPage')) const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage')) const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage')) +const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage')) const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage')) /** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */ @@ -309,6 +310,14 @@ const appRouter = createBrowserRouter([ ), }, + { + path: 'admin/ai-prompts', + element: ( + + + + ), + }, { path: 'trainer-contexts', element: }, ], }, diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index e4a0806..4bc7edb 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -13,13 +13,10 @@ export function mergeActiveClubHeader(headers = {}) { if (cid && /^\d+$/.test(String(cid).trim())) { return { ...headers, 'X-Active-Club-Id': String(cid).trim() } } - return { ...headers } + return headers } -/** - * Generischer API-Aufruf inkl. X-Auth-Token und X-Active-Club-Id. - */ -export async function request(endpoint, options = {}) { +async function _fetchWithAuth(endpoint, options = {}) { const token = localStorage.getItem('authToken') const method = (options.method || 'GET').toUpperCase() @@ -65,7 +62,7 @@ export async function request(endpoint, options = {}) { throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`) } - return response.json() + return response } catch (e) { if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) { const hint = @@ -77,3 +74,22 @@ export async function request(endpoint, options = {}) { throw e } } + +/** + * Generischer API-Aufruf inkl. X-Auth-Token und X-Active-Club-Id. + */ +export async function request(endpoint, options = {}) { + const response = await _fetchWithAuth(endpoint, options) + return response.json() +} + +/** Text-Download (z. B. CSV-Export) mit gleicher Auth wie request(). */ +export async function requestText(endpoint, options = {}) { + const response = await _fetchWithAuth(endpoint, options) + const disposition = response.headers.get('Content-Disposition') || '' + const match = disposition.match(/filename="([^"]+)"/) + return { + text: await response.text(), + filename: match ? match[1] : null, + } +} diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index 8681130..7fc5c75 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router-dom' -import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain } from 'lucide-react' +import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles } from 'lucide-react' /** * Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant). @@ -12,7 +12,7 @@ export default function AdminPageNav() { { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, { to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }, { to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale }, - { to: '/admin/ai-skill-retrieval', label: 'KI Retrieval', icon: Brain }, + { to: '/admin/ai-prompts', label: 'KI Prompts', icon: Sparkles }, ] return ( diff --git a/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx b/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx index 930a33c..0c51d29 100644 --- a/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx +++ b/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx @@ -13,6 +13,16 @@ function downloadJson(obj, filename) { URL.revokeObjectURL(url) } +function downloadText(text, filename, mime = 'text/csv;charset=utf-8') { + const blob = new Blob([text], { type: mime }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + function groupModelSkills(model) { if (!model?.model_skills?.length) return [] const groups = new Map() @@ -56,6 +66,8 @@ export default function MaturityMatrixToolsAdmin() { const [stackConfirmText, setStackConfirmText] = useState('') const [stackLoading, setStackLoading] = useState(false) const [stackImportLoading, setStackImportLoading] = useState(false) + const [editorLoading, setEditorLoading] = useState(false) + const [editorImportLoading, setEditorImportLoading] = useState(false) useEffect(() => { let cancelled = false @@ -144,6 +156,94 @@ export default function MaturityMatrixToolsAdmin() { } } + async function handleExportEditorJson() { + setError('') + setMessage('') + setEditorLoading(true) + try { + const bundle = await api.exportMatrixEditorBundle() + const name = `matrix-editor-${(bundle.bundle_export_id || 'export').slice(0, 8)}.json` + downloadJson(bundle, name) + setMessage('Editor-Export (JSON) heruntergeladen.') + } catch (err) { + setError(err.message || String(err)) + } finally { + setEditorLoading(false) + } + } + + async function handleExportEditorCsv(part) { + setError('') + setMessage('') + setEditorLoading(true) + try { + const { text, filename } = await api.exportMatrixEditorCsv(part) + downloadText( + text, + filename || (part === 'skills' ? 'faehigkeiten-katalog.csv' : 'faehigkeitsmatrix-zellen.csv') + ) + setMessage(part === 'skills' ? 'Katalog-CSV heruntergeladen.' : 'Matrix-CSV heruntergeladen.') + } catch (err) { + setError(err.message || String(err)) + } finally { + setEditorLoading(false) + } + } + + async function handleImportEditorJson(e) { + const file = e.target.files?.[0] + if (!file) return + setError('') + setMessage('') + setEditorImportLoading(true) + try { + const data = JSON.parse(await file.text()) + if (data.kind !== 'shinkan.matrix_editor.v1') { + setError('Erwartet wird kind: shinkan.matrix_editor.v1') + return + } + const res = await api.importMatrixEditorBundle(data) + const w = res.warnings || [] + setMessage( + `Import OK: ${res.skills_updated || 0} Fähigkeit(en), ${res.matrix_cells_updated || 0} Matrix-Zelle(n) aktualisiert.` + + (w.length ? ` ${w.length} Hinweis(e) in der Konsole.` : '') + ) + if (w.length) console.warn('matrix_editor import warnings', w) + } catch (err) { + setError(err.message || String(err)) + } finally { + setEditorImportLoading(false) + e.target.value = '' + } + } + + async function handleImportEditorCsv(e, part) { + const file = e.target.files?.[0] + if (!file) return + setError('') + setMessage('') + setEditorImportLoading(true) + try { + const csv_text = await file.text() + const res = await api.importMatrixEditorBundle({ + kind: 'shinkan.matrix_editor.csv', + part, + csv_text + }) + const w = res.warnings || [] + setMessage( + `CSV-Import (${part}) OK: ${res.skills_updated || 0} Fähigkeit(en), ${res.matrix_cells_updated || 0} Matrix-Zelle(n) aktualisiert.` + + (w.length ? ` ${w.length} Hinweis(e) in der Konsole.` : '') + ) + if (w.length) console.warn('matrix_editor csv import warnings', w) + } catch (err) { + setError(err.message || String(err)) + } finally { + setEditorImportLoading(false) + e.target.value = '' + } + } + async function handleExportStack() { setError('') setMessage('') @@ -234,9 +334,8 @@ export default function MaturityMatrixToolsAdmin() { return (

- Matrix nach Kontext auflösen, hierarchisch nach Hauptkategorie und Kategorie darstellen, sowie JSON - exportieren oder importieren (gespeichertes Modell inkl. optional Kontext-Bindings, aufgelöste Matrix, oder{' '} - Komplett-Stack mit Fähigkeitskatalog und allen Reifegradmodellen für Test → Prod). + Zentral Beschreibungen und Gewichtungen pflegen (Superadmin), Matrix nach Kontext anzeigen, sowie + vollständige JSON-Stacks für Test → Prod.

{error ? ( @@ -246,6 +345,76 @@ export default function MaturityMatrixToolsAdmin() { ) : null} {message ?

{message}

: null} +
+

Zentral bearbeiten (Export / Import)

+

+ Flaches Format für Excel oder JSON-Editor: Fähigkeits-Beschreibungen,{' '} + importance (Gewichtung 1–5), globale Stufen-Texte ( + level_1level_5 + ), Matrix-Zelltexte und Zeilen-Relevanz pro Reifegradmodell. Import aktualisiert bestehende Einträge — + es werden keine neuen Fähigkeiten oder Modelle angelegt. +

+
+ + + +
+
+
+

JSON importieren

+ + +
+
+

CSV importieren

+

+ Nur geänderte Datei hochladen — Katalog-CSV oder Matrix-CSV getrennt. +

+ + handleImportEditorCsv(e, 'skills')} + /> + + handleImportEditorCsv(e, 'matrix')} + /> +
+
+ {editorImportLoading ?

Import läuft…

: null} +
+

Kontext und Anzeige

diff --git a/frontend/src/pages/AdminAiPromptsPage.jsx b/frontend/src/pages/AdminAiPromptsPage.jsx new file mode 100644 index 0000000..de45cd1 --- /dev/null +++ b/frontend/src/pages/AdminAiPromptsPage.jsx @@ -0,0 +1,346 @@ +import { useCallback, useEffect, useState } from 'react' +import { Navigate } from 'react-router-dom' +import { Sparkles } from 'lucide-react' +import { useAuth } from '../context/AuthContext' +import api from '../utils/api' +import AdminPageNav from '../components/AdminPageNav' + +/** + * Pflege von ai_prompts (OpenRouter-Vorlagen) — nur Superadmin. + */ +export default function AdminAiPromptsPage() { + const { user } = useAuth() + const isSuperadmin = user?.role === 'superadmin' + + const [prompts, setPrompts] = useState([]) + const [catalog, setCatalog] = useState(null) + const [selectedId, setSelectedId] = useState(null) + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const [draftName, setDraftName] = useState('') + const [draftDesc, setDraftDesc] = useState('') + const [draftTemplate, setDraftTemplate] = useState('') + const [draftActive, setDraftActive] = useState(true) + + const [pvTitle, setPvTitle] = useState('Testübung') + const [pvGoal, setPvGoal] = useState('

Ziel hier

') + const [pvExec, setPvExec] = useState('

Ablauf hier

') + const [pvHint, setPvHint] = useState('') + const [pvFocusId, setPvFocusId] = useState('') + const [pvPreview, setPvPreview] = useState(null) + + const loadList = useCallback(async () => { + const [pList, cat] = await Promise.all([ + api.listAdminAiPrompts(), + api.getAdminAiPromptPlaceholdersCatalog(), + ]) + setPrompts(Array.isArray(pList) ? pList : []) + setCatalog(cat || null) + }, []) + + useEffect(() => { + if (!isSuperadmin) return + let cancelled = false + ;(async () => { + setLoading(true) + setError('') + try { + await loadList() + } catch (e) { + if (!cancelled) setError(e.message || String(e)) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [isSuperadmin, loadList]) + + useEffect(() => { + if (!isSuperadmin || !selectedId) { + setDetail(null) + return + } + let cancelled = false + ;(async () => { + try { + const d = await api.getAdminAiPrompt(selectedId) + if (!cancelled) { + setDetail(d) + setDraftName(d.display_name || '') + setDraftDesc(d.description || '') + setDraftTemplate(d.template || '') + setDraftActive(!!d.active) + setPvPreview(null) + } + } catch (e) { + if (!cancelled) setError(e.message || String(e)) + } + })() + return () => { + cancelled = true + } + }, [isSuperadmin, selectedId]) + + const save = async () => { + if (!detail?.id) return + setSaving(true) + setError('') + try { + await api.updateAdminAiPrompt(detail.id, { + template: draftTemplate, + display_name: draftName, + description: draftDesc, + active: draftActive, + }) + await loadList() + const nd = await api.getAdminAiPrompt(detail.id) + setDetail(nd) + setPvPreview(null) + } catch (e) { + setError(e.message || String(e)) + } finally { + setSaving(false) + } + } + + const resetTemplate = async () => { + if (!detail?.id || !detail.has_reference_template) return + if (!confirm('Template auf gespeicherten Referenztext zurücksetzen?')) return + setSaving(true) + try { + const nd = await api.resetAdminAiPromptTemplate(detail.id) + setDetail(nd) + setDraftTemplate(nd.template || '') + await loadList() + } catch (e) { + setError(e.message || String(e)) + } finally { + setSaving(false) + } + } + + const runPreview = async () => { + if (!detail?.id) return + setError('') + try { + const body = { + title: pvTitle, + goal: pvGoal, + execution: pvExec, + focus_hint: pvHint || undefined, + } + const fid = parseInt(String(pvFocusId).trim(), 10) + if (Number.isFinite(fid) && fid >= 1) { + body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }] + } + const r = await api.previewAdminAiPrompt(detail.id, body) + setPvPreview(r) + } catch (e) { + setError(e.message || String(e)) + } + } + + if (!isSuperadmin) return + + if (loading) { + return ( +
+ +
+
+
+
+ ) + } + + return ( +
+ +
+ +

KI Prompts

+
+

+ Datenbankvorlagen (ai_prompts) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig + aufgelöst — die Vorschau unten ruft kein externes Modell auf. +

+ {error ?

{error}

: null} + +
+
+ Prompts +
    + {(prompts || []).map((p) => ( +
  • + +
  • + ))} +
+
+ +
+ {!selectedId ? ( +

Prompt links wählen.

+ ) : ( +
+
+
+ slug: {detail?.slug} · Ausgabe:{' '} + {detail?.output_format} · Kategorie: {detail?.category} +
+
+ + setDraftName(e.target.value)} /> +
+
+ +