diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md index b26eef7..0198951 100644 --- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md +++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md @@ -8,6 +8,7 @@ **Ist-Stand API (Superadmin):** - `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders` +- Spalte **`openrouter_model`** (Migration **070**): Optional pro Prompt-Zeile; OpenRouter **`model`**-Parameter; **`NULL`/leer ⇒ `OPENROUTER_MODEL`** aus der Umgebung. **Autor:** Claude Code **Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System @@ -36,6 +37,7 @@ steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet. |-------------|-----------| | `exercise_summary` | Generiert `exercises.summary` aus goal + execution | | `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung | +| `exercise_instruction_rewrite` | Überarbeitet Anleitung: goal, execution, preparation, trainer_notes (JSON, prägnantes HTML) | | `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe | | `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix | | `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten | diff --git a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md index e75de69..b7535f5 100644 --- a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md +++ b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md @@ -26,7 +26,7 @@ Alle produktiven KI-Aufrufe sollten mittelfristig über eine **einheitliche Fass Router und Frontend rufen diese Schicht oder schmale Orchestratoren — **nicht** direkt `httpx`/OpenRouter an jeder Ecke verteilt. -**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` mit **Kontext-Arten** und **gemeinsamen DB-Ladeschritten** für `ai_prompts`; Übungs-KI konsumiert diese Schicht ohne Zirkelschluss zu Domänlogik (`exercise_ai`). +**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` (`load_ai_prompt_row`, `load_and_render_ai_prompt`, Kontext-Arten) sowie `backend/ai_prompt_job.py` (Pydantic `ExerciseFormAiPromptContext` fuer Uebungs-Prompts — Admin-Vorschau + erweiterbare Router-Nutzung); `exercise_ai` orchestriert OpenRouter nach dem Rendern. ### 2.2 Trennung: Semantik vs. Transport @@ -122,23 +122,25 @@ Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter, ```mermaid flowchart LR - subgraph heute + subgraph laufzeit A[ai_prompts DB] B[prompt_resolver Mustache] - C[ai_prompt_runtime Loader + ContextKind] - D[exercise_ai] + C[ai_prompt_runtime] + J[ai_prompt_job Pydantic] + D[exercise_ai OpenRouter] end - A --> B A --> C + C --> B + J --> D C --> D B --> D ``` | Phase | Inhalt | |-------|--------| -| **P0 (gestartet)** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI nutzt Laufzeit; Platzhalter-Katalog pro Kontext erweiterbar. | -| **P1** | Einheitliche `run_ai_job`-Fassade (Slug + Kind + Pydantic-Payload + Validierung); Router nur noch dünne Adapter. | -| **P2** | Versionierung oder Audit-Spalten; optionale Modell-/Temperatur-Overrides pro Slug in DB oder Config-Tabelle. | +| **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. | +| **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. | @@ -157,7 +159,7 @@ flowchart LR - Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md` - Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md` - Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md` -- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py` +- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`, `backend/ai_prompt_context.py`, `backend/ai_prompt_job.py` --- diff --git a/.env.example b/.env.example index 91f9c07..66c24e7 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,8 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD OPENROUTER_API_KEY=your_api_key_here OPENROUTER_MODEL=anthropic/claude-sonnet-4 +# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model +# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“). # Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container. # Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter). diff --git a/backend/ai_prompt_context.py b/backend/ai_prompt_context.py new file mode 100644 index 0000000..bd441b4 --- /dev/null +++ b/backend/ai_prompt_context.py @@ -0,0 +1,105 @@ +""" +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 / Skills / Anleitung). + + Wird genutzt von Admin-Prompt-Vorschau und POST /exercises/ai/suggest (via Mapping). + """ + + title: Optional[str] = "" + goal: Optional[str] = None + execution: Optional[str] = None + preparation: Optional[str] = None + trainer_notes: 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 has_instruction_source_text(self) -> bool: + """Mindestens ein Anleitungsfeld oder Titel fuer instruction_rewrite.""" + if (self.title or "").strip(): + return True + for val in (self.goal, self.execution, self.preparation, self.trainer_notes): + if val and str(val).strip(): + return True + return False + + @classmethod + def from_api_suggest( + cls, + *, + title: Optional[str] = None, + goal: Optional[str] = None, + execution: Optional[str] = None, + preparation: Optional[str] = None, + trainer_notes: 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, + preparation=preparation, + trainer_notes=trainer_notes, + 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, + preparation: Optional[str] = None, + trainer_notes: 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, + preparation=preparation, + trainer_notes=trainer_notes, + focus_hint=(focus_hint or "").strip() or None, + focus_areas_context=rows, + ) + + +__all__ = [ + "ExerciseFormAiFocusRow", + "ExerciseFormAiPromptContext", +] diff --git a/backend/ai_prompt_job.py b/backend/ai_prompt_job.py new file mode 100644 index 0000000..38074ba --- /dev/null +++ b/backend/ai_prompt_job.py @@ -0,0 +1,58 @@ +""" +KI-Prompt Jobs: Resolver + oeffentliche Fassade fuer Uebungs-KI-Aufrufe. + +Importiert exercise_ai fuer Platzhalter-Builder und OpenRouter-Orchestrierung. +""" +from __future__ import annotations + +from typing import Any, Dict + +from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext +from exercise_ai import build_exercise_placeholder_variables + + +def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptContext) -> Dict[str, str]: + """Baut die Mustache-Map fuer exercise_summary / exercise_skill_suggestions.""" + return build_exercise_placeholder_variables( + cur, + slug=slug, + title=(ctx.title or "").strip(), + goal=ctx.goal, + execution=ctx.execution, + focus_area_hint=ctx.focus_hint, + focus_areas_context=ctx.focus_area_tuples(), + preparation=ctx.preparation, + trainer_notes=ctx.trainer_notes, + ) + + +def run_exercise_form_ai_suggestion( + cur, + ctx: ExerciseFormAiPromptContext, + *, + want_summary: bool, + want_skills: bool, + want_instructions: bool = False, +) -> 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, + want_instructions=want_instructions, + ) + + +__all__ = [ + "ExerciseFormAiFocusRow", + "ExerciseFormAiPromptContext", + "resolve_exercise_form_variables", + "run_exercise_form_ai_suggestion", +] diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py index 4ca8560..315c1d2 100644 --- a/backend/ai_prompt_runtime.py +++ b/backend/ai_prompt_runtime.py @@ -7,12 +7,15 @@ load_ai_prompt_row und die Enum; Platzhalter bauen sie selbst oder über geteilt from __future__ import annotations from enum import Enum -from typing import Any, Dict, Optional +from typing import Any, Dict, Mapping, Optional, Tuple + +from prompt_resolver import MustacheRenderResult, render_mustache_template _EXERCISE_AI_SLUGS = frozenset( { "exercise_summary", "exercise_skill_suggestions", + "exercise_instruction_rewrite", } ) @@ -43,7 +46,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[ if active_only: cur.execute( """ - SELECT slug, display_name, template, output_format, active + SELECT slug, display_name, template, output_format, active, openrouter_model FROM ai_prompts WHERE slug = %s AND active = true """, @@ -52,7 +55,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[ else: cur.execute( """ - SELECT slug, display_name, template, output_format, active + SELECT slug, display_name, template, output_format, active, openrouter_model FROM ai_prompts WHERE slug = %s """, @@ -67,8 +70,45 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[ return d +class AiPromptUnavailableError(LookupError): + """Kein aktiver Prompt fuer slug (oder Zeile fehlt).""" + + def __init__(self, slug: str) -> None: + self.slug = (slug or "").strip() + super().__init__(self.slug) + + +def render_ai_prompt_template_for_row( + row: Mapping[str, Any], + variables: Mapping[str, str], +) -> MustacheRenderResult: + """Ersetzt Platzhalter anhand einer bereits geladenen ai_prompts-Zeile (z. B. Admin-Vorschauch, inkl. inaktiv).""" + return render_mustache_template(str(row.get("template") or ""), variables) + + +def load_and_render_ai_prompt( + cur, + slug: str, + variables: Mapping[str, str], + *, + active_only: bool = True, +) -> Tuple[Dict[str, Any], MustacheRenderResult]: + """ + Laedt einen aktiven Prompt und wendet Mustache-Variablen an. + Wirft AiPromptUnavailableError, wenn die Zeile fehlt oder (bei active_only) inaktiv ist. + """ + row = load_ai_prompt_row(cur, slug, active_only=active_only) + if not row: + raise AiPromptUnavailableError(slug) + rr = render_ai_prompt_template_for_row(row, variables) + return dict(row), rr + + __all__ = [ "AiPromptContextKind", + "AiPromptUnavailableError", "context_kind_for_slug", "load_ai_prompt_row", + "load_and_render_ai_prompt", + "render_ai_prompt_template_for_row", ] diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py index 6391740..5b1c73e 100644 --- a/backend/exercise_ai.py +++ b/backend/exercise_ai.py @@ -7,6 +7,7 @@ Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, F from __future__ import annotations import copy +import html import json import logging import math @@ -16,10 +17,17 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, from fastapi import HTTPException -from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion +from openrouter_chat import ( + OpenRouterError, + default_openrouter_model_id, + effective_openrouter_model_for_prompt_row, + normalize_openrouter_env, + openrouter_chat_completion, +) -from ai_prompt_runtime import load_ai_prompt_row -from prompt_resolver import render_mustache_template +from ai_prompt_context import ExerciseFormAiPromptContext +from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt +from exercise_rich_text import collect_inline_exercise_media_ids, normalize_inline_exercise_media_markup _LOGGER = logging.getLogger("shinkan.exercise_ai") @@ -491,6 +499,146 @@ def build_contextual_skills_catalog_block( return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)" +_MAX_INSTRUCTION_GOAL_PLAIN = 4_000 +_MAX_INSTRUCTION_EXECUTION_PLAIN = 12_000 +_MAX_INSTRUCTION_PREP_PLAIN = 2_500 +_MAX_INSTRUCTION_TRAINER_PLAIN = 2_500 + +_INSTRUCTION_JSON_KEYS = ("goal", "execution", "preparation", "trainer_notes") +_INSTRUCTION_FIELD_MAX_PLAIN = { + "goal": _MAX_INSTRUCTION_GOAL_PLAIN, + "execution": _MAX_INSTRUCTION_EXECUTION_PLAIN, + "preparation": _MAX_INSTRUCTION_PREP_PLAIN, + "trainer_notes": _MAX_INSTRUCTION_TRAINER_PLAIN, +} + +_DISALLOWED_HTML_TAG_RE = re.compile( + r"]*>", + re.IGNORECASE, +) +_SCRIPT_STYLE_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?") + + +def _plain_to_minimal_instruction_html(text: str) -> str: + raw = (text or "").strip() + if not raw: + return "" + parts = [p.strip() for p in re.split(r"\n+", raw) if p.strip()] + if not parts: + return "" + return "".join(f"

{html.escape(p)}

" for p in parts) + + +def _truncate_plain(text: str, max_len: int) -> str: + t = (text or "").strip() + if len(t) <= max_len: + return t + return t[: max_len - 1].rstrip() + "…" + + +def _sanitize_instruction_field_html(raw: Any, *, max_plain: int) -> str: + if raw is None: + return "" + s = str(raw).strip() + if not s: + return "" + if s.startswith("```"): + s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) + if s.endswith("```"): + s = s[:-3].strip() + s = _SCRIPT_STYLE_RE.sub("", s) + s = _DISALLOWED_HTML_TAG_RE.sub("", s) + if "<" not in s: + s = _plain_to_minimal_instruction_html(s) + else: + s = normalize_inline_exercise_media_markup(s) or "" + plain = strip_html_to_plain(s, max_len=max_plain + 200) + if len(plain) > max_plain: + plain = _truncate_plain(plain, max_plain) + s = _plain_to_minimal_instruction_html(plain) + return (normalize_inline_exercise_media_markup(s) or "").strip() + + +def _merge_preserved_inline_media(original: Optional[str], revised: str) -> str: + """Haengt fehlende Medien-Verweise aus dem Ausgangstext ans Ende an.""" + out = (revised or "").strip() + orig_ids = collect_inline_exercise_media_ids(original) + if not orig_ids: + return out + new_ids = collect_inline_exercise_media_ids(out) + missing = sorted(orig_ids - new_ids) + if not missing: + return out + spans = [] + for mid in missing: + spans.append( + f'' + ) + block = f"

{''.join(spans)}

" + return (out + block).strip() if out else block + + +def _first_balanced_json_object(text: str) -> Optional[str]: + i = text.find("{") + if i < 0: + return None + depth = 0 + in_str = False + esc = False + for j in range(i, len(text)): + ch = text[j] + if in_str: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return text[i : j + 1] + return None + + +def _extract_instruction_rewrite_object(text: str) -> Dict[str, Any]: + s = (text or "").strip() + if not s: + raise ValueError("leer") + if s.startswith("```"): + s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) + if s.endswith("```"): + s = s[:-3].strip() + frag = _first_balanced_json_object(s) + if frag: + s = frag + obj = json.loads(s) + if not isinstance(obj, dict): + raise ValueError("kein JSON-Objekt") + return obj + + +def _sanitize_instruction_rewrite_payload( + parsed: Mapping[str, Any], + *, + originals: Mapping[str, Optional[str]], +) -> Dict[str, str]: + out: Dict[str, str] = {} + for key in _INSTRUCTION_JSON_KEYS: + max_plain = _INSTRUCTION_FIELD_MAX_PLAIN[key] + html = _sanitize_instruction_field_html(parsed.get(key), max_plain=max_plain) + html = _merge_preserved_inline_media(originals.get(key), html) + out[key] = html + return out + + def build_exercise_placeholder_variables( cur, *, @@ -500,6 +648,8 @@ def build_exercise_placeholder_variables( execution: Optional[str], focus_area_hint: Optional[str], focus_areas_context: Optional[Sequence[Tuple[int, bool]]], + preparation: Optional[str] = None, + trainer_notes: Optional[str] = None, ) -> Dict[str, str]: """ Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI. @@ -509,6 +659,8 @@ def build_exercise_placeholder_variables( return {} g_plain = strip_html_to_plain(goal) e_plain = strip_html_to_plain(execution) + p_plain = strip_html_to_plain(preparation) + n_plain = strip_html_to_plain(trainer_notes) t_title = (title or "").strip() focus = (focus_area_hint or "").strip() ctx: Dict[str, str] = { @@ -516,8 +668,12 @@ def build_exercise_placeholder_variables( "exercise_focus_area": focus or "-", "exercise_goal": g_plain or "-", "exercise_execution": e_plain or "-", + "exercise_preparation": p_plain or "-", + "exercise_trainer_notes": n_plain or "-", } if s == "exercise_summary": + return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")} + if s == "exercise_instruction_rewrite": return ctx if s == "exercise_skill_suggestions": catalog = build_contextual_skills_catalog_block( @@ -664,32 +820,43 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]: return out[:5] -def _require_openrouter() -> Tuple[str, str]: - key, model = normalize_openrouter_env() +def _require_openrouter_key() -> str: + key, _ = normalize_openrouter_env() if not key: raise HTTPException( status_code=503, detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).", ) - return key, model + return key def run_exercise_ai_suggestion( 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, + want_instructions: bool = False, ) -> Dict[str, Any]: - key, model = _require_openrouter() + key = _require_openrouter_key() + + title = form_ctx.title + goal = form_ctx.goal + execution = form_ctx.execution + preparation = form_ctx.preparation + trainer_notes = form_ctx.trainer_notes + 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()): + if want_instructions: + if not form_ctx.has_instruction_source_text(): + raise HTTPException( + status_code=400, + detail="Fuer Anleitungs-Ueberarbeitung mindestens Titel oder ein Anleitungsfeld ausfuellen.", + ) + elif not (g_plain.strip() or e_plain.strip()): raise HTTPException( status_code=400, detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).", @@ -698,26 +865,25 @@ def run_exercise_ai_suggestion( t_title = (title or "").strip() focus = (focus_area_hint or "").strip() - result: Dict[str, Any] = {"model": model} + result: Dict[str, Any] = {} + models_by_slug: Dict[str, str] = {} if _ai_debug_on(): fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context)) _LOGGER.warning( - "AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s " - "exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]", + "AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s want_instructions=%s " + "title_chars=%s goal_plain_chars=%s exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]", want_summary, want_skills, - len(t_title), + want_instructions, + len((title or "").strip()), len(g_plain), len(e_plain), - len(focus), + len((focus_area_hint or "").strip()), fid_list, ) if want_summary: - prow = load_ai_prompt_row(cur, "exercise_summary") - if not prow: - raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.") try: ctx = build_exercise_placeholder_variables( cur, @@ -730,7 +896,15 @@ def run_exercise_ai_suggestion( ) except ValueError as e: raise HTTPException(status_code=500, detail=str(e)) from e - rendered = render_mustache_template(str(prow["template"]), ctx) + try: + prow, rendered = load_and_render_ai_prompt(cur, "exercise_summary", ctx) + except AiPromptUnavailableError: + raise HTTPException( + status_code=503, + detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.", + ) from None + model_summary = effective_openrouter_model_for_prompt_row(prow) + models_by_slug["exercise_summary"] = model_summary prompt = rendered.text if _ai_debug_on(): _LOGGER.warning( @@ -739,7 +913,7 @@ def run_exercise_ai_suggestion( len(rendered.placeholders_remaining), ) try: - raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt) + raw = openrouter_chat_completion(api_key=key, model=model_summary, user_content=prompt) except OpenRouterError as e: raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e if _ai_debug_on(): @@ -752,15 +926,9 @@ def run_exercise_ai_suggestion( ) if len(text) > _MAX_SUMMARY_CHARS: text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…" - result["summary"] = {"text": text, "ai_generated": True, "model": model} + result["summary"] = {"text": text, "ai_generated": True, "model": model_summary} if want_skills: - srow = load_ai_prompt_row(cur, "exercise_skill_suggestions") - if not srow: - raise HTTPException( - status_code=503, - detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.", - ) try: ctx = build_exercise_placeholder_variables( cur, @@ -773,7 +941,15 @@ def run_exercise_ai_suggestion( ) except ValueError as e: raise HTTPException(status_code=500, detail=str(e)) from e - rendered = render_mustache_template(str(srow["template"]), ctx) + try: + srow, rendered = load_and_render_ai_prompt(cur, "exercise_skill_suggestions", ctx) + except AiPromptUnavailableError: + raise HTTPException( + status_code=503, + detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.", + ) from None + model_skills = effective_openrouter_model_for_prompt_row(srow) + models_by_slug["exercise_skill_suggestions"] = model_skills prompt = rendered.text if _ai_debug_on(): _LOGGER.warning( @@ -789,7 +965,7 @@ def run_exercise_ai_suggestion( try: raw = openrouter_chat_completion( api_key=key, - model=model, + model=model_skills, user_content=prompt, system_content=sys_hint, temperature=0.15, @@ -827,6 +1003,97 @@ def run_exercise_ai_suggestion( result["skills"] = skills + if want_instructions: + try: + ctx = build_exercise_placeholder_variables( + cur, + slug="exercise_instruction_rewrite", + title=title, + goal=goal, + execution=execution, + preparation=preparation, + trainer_notes=trainer_notes, + 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 + try: + irow, rendered = load_and_render_ai_prompt(cur, "exercise_instruction_rewrite", ctx) + except AiPromptUnavailableError: + raise HTTPException( + status_code=503, + detail="Prompt exercise_instruction_rewrite nicht aktiv oder fehlt in DB.", + ) from None + model_instr = effective_openrouter_model_for_prompt_row(irow) + models_by_slug["exercise_instruction_rewrite"] = model_instr + prompt = rendered.text + if _ai_debug_on(): + _LOGGER.warning( + "AI_DEBUG exercise_ai instructions prompt_slug=exercise_instruction_rewrite prompt_chars=%s", + len(prompt), + ) + sys_hint = ( + "Du antwortest nur mit validem JSON-Objekt (Schluessel goal, execution, preparation, trainer_notes). " + "Keine Kommentare ausserhalb des JSON." + ) + try: + raw = openrouter_chat_completion( + api_key=key, + model=model_instr, + user_content=prompt, + system_content=sys_hint, + temperature=0.2, + ) + except OpenRouterError as e: + raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e + body = (raw or "").strip() + if not body: + raise HTTPException( + status_code=502, + detail="OpenRouter/KI lieferte leeren Inhalt fuer Anleitungs-Ueberarbeitung.", + ) + try: + parsed = _extract_instruction_rewrite_object(body) + except (json.JSONDecodeError, ValueError) as e: + if _ai_debug_on(): + _LOGGER.warning( + "AI_DEBUG exercise_ai instructions JSON parse_failed err=%s head=%s", + e, + (body.replace("\r", "").replace("\n", " ").strip())[:400], + ) + raise HTTPException( + status_code=502, + detail="KI lieferte kein verwertbares JSON fuer die Anleitung.", + ) from e + originals = { + "goal": goal, + "execution": execution, + "preparation": preparation, + "trainer_notes": trainer_notes, + } + fields = _sanitize_instruction_rewrite_payload(parsed, originals=originals) + if not any((fields.get(k) or "").strip() for k in _INSTRUCTION_JSON_KEYS): + raise HTTPException( + status_code=502, + detail="KI lieferte leere Anleitungs-Felder.", + ) + result["instructions"] = { + "fields": fields, + "ai_generated": True, + "model": model_instr, + } + + result["models_by_slug"] = models_by_slug + if want_skills: + result["model"] = models_by_slug["exercise_skill_suggestions"] + elif want_instructions: + result["model"] = models_by_slug["exercise_instruction_rewrite"] + elif want_summary: + result["model"] = models_by_slug["exercise_summary"] + else: + result["model"] = default_openrouter_model_id() + return result diff --git a/backend/migrations/070_ai_prompts_openrouter_model.sql b/backend/migrations/070_ai_prompts_openrouter_model.sql new file mode 100644 index 0000000..2977037 --- /dev/null +++ b/backend/migrations/070_ai_prompts_openrouter_model.sql @@ -0,0 +1,7 @@ +-- Migration 070: optionales OpenRouter-Modell pro Prompt-Zeile +-- Leer/NULL → Umgebungsvariable OPENROUTER_MODEL (wie bisher). + +ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS openrouter_model VARCHAR(200); + +COMMENT ON COLUMN ai_prompts.openrouter_model IS + 'Optional: OpenRouter model id (z.B. anthropic/claude-3.5-haiku); NULL = OPENROUTER_MODEL aus Env'; diff --git a/backend/migrations/071_ai_prompt_exercise_instruction_rewrite.sql b/backend/migrations/071_ai_prompt_exercise_instruction_rewrite.sql new file mode 100644 index 0000000..612c189 --- /dev/null +++ b/backend/migrations/071_ai_prompt_exercise_instruction_rewrite.sql @@ -0,0 +1,59 @@ +-- Migration 071: KI-Prompt fuer Anleitungs-Ueberarbeitung (Ziel, Durchfuehrung, Vorbereitung, Trainer-Hinweise) +-- JSON-Ausgabe; praezise HTML-Fragmente fuer RichTextEditor. + +INSERT INTO ai_prompts ( + slug, display_name, description, template, + category, output_format, output_schema, is_system_default, default_template, active, sort_order +) +SELECT + 'exercise_instruction_rewrite', + 'Anleitung ueberarbeiten', + 'Ueberarbeitet Ziel, Durchfuehrung, Vorbereitung und Trainer-Hinweise — praezise, strukturiert, ohne Aufblaehen.', + $t$Du bist Assistent fuer Kampfsport-Trainer. +Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen. +Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing. + +Stil: +- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte) +- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels) +- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze) +- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String +- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze + +Format (HTML fuer Rich-Text-Editor): +- Erlaubt:

,