diff --git a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md index 26e789c..b7535f5 100644 --- a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md +++ b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md @@ -139,7 +139,7 @@ flowchart LR | Phase | Inhalt | |-------|--------| | **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. | -| **P1 (teilweise)** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **Pydantic** `ExerciseFormAiPromptContext` / `resolve_exercise_form_variables` in `ai_prompt_job` (Admin-Vorschau + gemeinsame Schnittstelle). Nächster Schritt: `POST /exercises/ai/suggest` kann dasselbe Kontextmodell nutzen; optionale `execute_*`-Fassade fuer OpenRouter-Schritt. | +| **P1** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **`ExerciseFormAiPromptContext`** in `ai_prompt_context.py`; **`run_exercise_form_ai_suggestion`**; Übungs-API und Admin-Vorschau nutzen denselben Kontext. | | **P2** | Versionierung oder Audit-Spalten; **teilweise:** optionales OpenRouter-Modell pro Zeile (`openrouter_model`, Migration 070, Fallback `OPENROUTER_MODEL`); weitere Overrides (Temperatur) offen. | | **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. | | **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. | @@ -159,7 +159,7 @@ flowchart LR - Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md` - Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md` - Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md` -- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`, `backend/ai_prompt_job.py` +- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`, `backend/ai_prompt_context.py`, `backend/ai_prompt_job.py` --- diff --git a/backend/ai_prompt_context.py b/backend/ai_prompt_context.py new file mode 100644 index 0000000..1d15f36 --- /dev/null +++ b/backend/ai_prompt_context.py @@ -0,0 +1,86 @@ +""" +Gemeinsame Pydantic-Modelle fuer Uebungs-KI-Kontext (Formularfelder → Prompt-Platzhalter). + +Keine Imports aus exercise_ai — vermeidet Zirkelimporte mit ai_prompt_job / exercise_ai. +""" +from __future__ import annotations + +from typing import List, Optional, Sequence, Tuple + +from pydantic import BaseModel, Field + + +class ExerciseFormAiFocusRow(BaseModel): + """Fokusbereich fuer Skill-Retrieval (ai_skill_retrieval_profiles).""" + + focus_area_id: int = Field(..., ge=1) + is_primary: Optional[bool] = False + + +class ExerciseFormAiPromptContext(BaseModel): + """ + Inhaltliche Eingabe fuer Uebungs-Prompts (Kurzfassung / Skill-Vorschlaege). + + Wird genutzt von Admin-Prompt-Vorschau und POST /exercises/ai/suggest (via Mapping). + """ + + title: Optional[str] = "" + goal: Optional[str] = None + execution: Optional[str] = None + focus_hint: Optional[str] = None + focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None + + def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]: + if not self.focus_areas_context: + return None + return [(int(x.focus_area_id), bool(x.is_primary)) for x in self.focus_areas_context] + + @classmethod + def from_api_suggest( + cls, + *, + title: Optional[str] = None, + goal: Optional[str] = None, + execution: Optional[str] = None, + focus_area_hint: Optional[str] = None, + focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None, + ) -> ExerciseFormAiPromptContext: + """Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint).""" + hint = (focus_area_hint or "").strip() or None + return cls( + title=(title or "").strip(), + goal=goal, + execution=execution, + focus_hint=hint, + focus_areas_context=list(focus_areas_context) if focus_areas_context else None, + ) + + @classmethod + def from_focus_tuples( + cls, + *, + title: str = "", + goal: Optional[str] = None, + execution: Optional[str] = None, + focus_hint: Optional[str] = None, + focus_tuples: Optional[Sequence[Tuple[int, bool]]] = None, + ) -> ExerciseFormAiPromptContext: + rows = None + if focus_tuples: + rows = [ + ExerciseFormAiFocusRow(focus_area_id=int(fid), is_primary=bool(prim)) + for fid, prim in focus_tuples + ] + return cls( + title=(title or "").strip(), + goal=goal, + execution=execution, + focus_hint=(focus_hint or "").strip() or None, + focus_areas_context=rows, + ) + + +__all__ = [ + "ExerciseFormAiFocusRow", + "ExerciseFormAiPromptContext", +] diff --git a/backend/ai_prompt_job.py b/backend/ai_prompt_job.py index b5e602f..daf73fc 100644 --- a/backend/ai_prompt_job.py +++ b/backend/ai_prompt_job.py @@ -1,47 +1,16 @@ """ -KI-Prompt Jobs: validierter Kontext (Pydantic) + Zugriff auf gemeinsame Resolver. +KI-Prompt Jobs: Resolver + oeffentliche Fassade fuer Uebungs-KI-Aufrufe. -Importiert exercise_ai nur fuer Platzhalter-Builder — Router importieren dieses Modul oder exercise_ai, -nicht umgekehrt von exercise_ai nach ai_prompt_job (Zirkel vermeiden). +Importiert exercise_ai fuer Platzhalter-Builder und OpenRouter-Orchestrierung. """ from __future__ import annotations -from typing import Dict, List, Optional, Tuple - -from pydantic import BaseModel, Field +from typing import Any, Dict +from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext from exercise_ai import build_exercise_placeholder_variables -class ExerciseFormAiFocusRow(BaseModel): - """Fokusbereich fuer Skill-Retrieval-Kontext (wie ExerciseAiFocusCtx / Admin-Preview).""" - - focus_area_id: int = Field(..., ge=1) - is_primary: Optional[bool] = False - - -class ExerciseFormAiPromptContext(BaseModel): - """ - Eingabe fuer Uebungsbezogene Prompts (Kurzfassung / Skill-JSON-Vorschlag). - Entspricht fachlich dem Preview-Body unter /api/admin/ai-prompts/*/preview. - - Abgrenzung: POST /exercises/ai/suggest nutzt ExerciseAiSuggestBody mit include_summary / - include_skills usw.; dieses Modell bildet nur die gemeinsamen Formularfelder — wie die - Admin-Vorschau-Body (AiPromptPreviewBody). - """ - - title: Optional[str] = "" - goal: Optional[str] = None - execution: Optional[str] = None - focus_hint: Optional[str] = None - focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None - - def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]: - if not self.focus_areas_context: - return None - return [(int(x.focus_area_id), bool(x.is_primary)) for x in self.focus_areas_context] - - def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptContext) -> Dict[str, str]: """Baut die Mustache-Map fuer exercise_summary / exercise_skill_suggestions.""" return build_exercise_placeholder_variables( @@ -55,8 +24,31 @@ def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptCon ) +def run_exercise_form_ai_suggestion( + cur, + ctx: ExerciseFormAiPromptContext, + *, + want_summary: bool, + want_skills: bool, +) -> Dict[str, Any]: + """ + Fuehrt Uebungs-KI aus (OpenRouter) — ein Einstieg fuer Router und kuenftige Jobs. + + ``ctx`` = Formularinhalt; ``want_*`` = welche Prompt-Slugs angefragt werden. + """ + from exercise_ai import run_exercise_ai_suggestion + + return run_exercise_ai_suggestion( + cur, + form_ctx=ctx, + want_summary=want_summary, + want_skills=want_skills, + ) + + __all__ = [ "ExerciseFormAiFocusRow", "ExerciseFormAiPromptContext", "resolve_exercise_form_variables", + "run_exercise_form_ai_suggestion", ] diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py index 8b41509..9d2c622 100644 --- a/backend/exercise_ai.py +++ b/backend/exercise_ai.py @@ -24,6 +24,7 @@ from openrouter_chat import ( openrouter_chat_completion, ) +from ai_prompt_context import ExerciseFormAiPromptContext from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt _LOGGER = logging.getLogger("shinkan.exercise_ai") @@ -682,16 +683,18 @@ def _require_openrouter_key() -> str: def run_exercise_ai_suggestion( cur, *, - title: Optional[str], - goal: Optional[str], - execution: Optional[str], - focus_area_hint: Optional[str], - focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None, + form_ctx: ExerciseFormAiPromptContext, want_summary: bool, want_skills: bool, ) -> Dict[str, Any]: key = _require_openrouter_key() + title = form_ctx.title + goal = form_ctx.goal + execution = form_ctx.execution + focus_area_hint = form_ctx.focus_hint + focus_areas_context = form_ctx.focus_area_tuples() + g_plain = strip_html_to_plain(goal) e_plain = strip_html_to_plain(execution) if not (g_plain.strip() or e_plain.strip()): diff --git a/backend/routers/ai_prompts_admin.py b/backend/routers/ai_prompts_admin.py index dfefaec..fbccb91 100644 --- a/backend/routers/ai_prompts_admin.py +++ b/backend/routers/ai_prompts_admin.py @@ -12,7 +12,8 @@ from pydantic import BaseModel, Field from auth import require_auth from club_tenancy import is_superadmin -from ai_prompt_job import ExerciseFormAiPromptContext, resolve_exercise_form_variables +from ai_prompt_context import ExerciseFormAiPromptContext +from ai_prompt_job import resolve_exercise_form_variables from ai_prompt_runtime import render_ai_prompt_template_for_row from db import get_cursor, get_db, r2d from prompt_resolver import exercise_placeholder_catalog @@ -60,17 +61,8 @@ class AiPromptUpdateBody(BaseModel): openrouter_model: Optional[str] = Field(None, max_length=200) -class AiPromptPreviewFocus(BaseModel): - focus_area_id: int = Field(..., ge=1) - is_primary: Optional[bool] = False - - -class AiPromptPreviewBody(BaseModel): - title: Optional[str] = "" - goal: Optional[str] = None - execution: Optional[str] = None - focus_hint: Optional[str] = None - focus_areas_context: Optional[List[AiPromptPreviewFocus]] = None +class AiPromptPreviewBody(ExerciseFormAiPromptContext): + """Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint).""" @router.get("/api/admin/ai-prompts/catalog/placeholders") @@ -228,8 +220,7 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict = warn: Optional[str] = None if slug in ("exercise_summary", "exercise_skill_suggestions"): try: - pf_ctx = ExerciseFormAiPromptContext.model_validate(body.model_dump()) - vars_map = resolve_exercise_form_variables(cur, slug, pf_ctx) + vars_map = resolve_exercise_form_variables(cur, slug, body) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e elif slug == "pipeline": diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index d79b0b9..7d04dcc 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -35,7 +35,8 @@ from tenant_context import TenantContext, get_tenant_context, get_tenant_context from media_storage import get_effective_media_root, library_storage_key, path_under_media_root from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields from media_legal_hold import assert_not_under_legal_hold -from exercise_ai import run_exercise_ai_suggestion +from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext +from ai_prompt_job import run_exercise_form_ai_suggestion from exercise_rich_text import ( RICH_HTML_EXERCISE_FIELDS, @@ -358,11 +359,8 @@ class ExerciseMediaFromAsset(BaseModel): media_type: Optional[str] = None -class ExerciseAiFocusCtx(BaseModel): - """Fokusbereich fuer Skill-Kataloggewichte (Migration 068 ai_skill_retrieval_profiles).""" - - focus_area_id: int = Field(..., ge=1) - is_primary: Optional[bool] = False +class ExerciseAiFocusCtx(ExerciseFormAiFocusRow): + """Alias fuer OpenAPI — identisch zu ExerciseFormAiFocusRow.""" class ExerciseAiSuggestBody(BaseModel): @@ -370,7 +368,7 @@ class ExerciseAiSuggestBody(BaseModel): goal: Optional[str] = Field(None, max_length=64000) execution: Optional[str] = Field(None, max_length=128000) focus_area_hint: Optional[str] = Field(None, max_length=1200) - focus_areas_context: Optional[list[ExerciseAiFocusCtx]] = Field( + focus_areas_context: Optional[list[ExerciseFormAiFocusRow]] = Field( None, description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung", ) @@ -383,6 +381,15 @@ class ExerciseAiSuggestBody(BaseModel): raise ValueError("Mindestens include_summary oder include_skills aktivieren.") return self + def to_form_context(self) -> ExerciseFormAiPromptContext: + return ExerciseFormAiPromptContext.from_api_suggest( + title=self.title, + goal=self.goal, + execution=self.execution, + focus_area_hint=self.focus_area_hint, + focus_areas_context=self.focus_areas_context, + ) + class ExerciseAiRegenerateBody(BaseModel): """Welche Artefakte neu angefragt werden sollen.""" @@ -2306,17 +2313,9 @@ def exercise_ai_suggest_endpoint( _ = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) - fctx = None - if body.focus_areas_context: - fctx = [(x.focus_area_id, bool(x.is_primary)) for x in body.focus_areas_context] - - payload = run_exercise_ai_suggestion( + payload = run_exercise_form_ai_suggestion( cur, - title=(body.title or "").strip(), - goal=body.goal, - execution=body.execution, - focus_area_hint=(body.focus_area_hint or "").strip() or None, - focus_areas_context=fctx, + body.to_form_context(), want_summary=body.include_summary, want_skills=body.include_skills, ) @@ -2344,13 +2343,16 @@ def exercise_ai_regenerate_endpoint( focus = _focus_area_hint_from_detail(exercise) fctx = _focus_areas_ai_ctx_from_detail(exercise) - payload = run_exercise_ai_suggestion( - cur, + ctx = ExerciseFormAiPromptContext.from_focus_tuples( title=str(exercise.get("title") or "").strip(), goal=exercise.get("goal"), execution=exercise.get("execution"), - focus_area_hint=focus or None, - focus_areas_context=fctx or None, + focus_hint=focus or None, + focus_tuples=fctx or None, + ) + payload = run_exercise_form_ai_suggestion( + cur, + ctx, want_summary=want_summary, want_skills=want_skills, ) diff --git a/backend/version.py b/backend/version.py index 310c8e9..050abd7 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.161" +APP_VERSION = "0.8.162" BUILD_DATE = "2026-05-31" DB_SCHEMA_VERSION = "20260531070" @@ -20,13 +20,14 @@ MODULE_VERSIONS = { "media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets "admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068) "admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail - "ai_prompt_job": "0.1.0", # ExerciseFormAiPromptContext, resolve_exercise_form_variables — P1 Kontext-Schnittstelle + "ai_prompt_job": "0.2.0", # run_exercise_form_ai_suggestion; Kontext in ai_prompt_context + "ai_prompt_context": "0.1.0", # ExerciseFormAiPromptContext — Formularfelder fuer Uebungs-Prompts "ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row "groups": "0.1.0", "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", - "exercises": "2.31.3", # exercise_ai: OpenRouter-Modell pro Prompt-Slug; Response models_by_slug + "exercises": "2.31.4", # ai/suggest + regenerate: ExerciseFormAiPromptContext via run_exercise_form_ai_suggestion "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -41,6 +42,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.162", + "date": "2026-05-31", + "changes": [ + "KI Prompt P1 abgeschlossen: ai_prompt_context (Formular-Kontext), run_exercise_form_ai_suggestion als gemeinsamer Einstieg;", + "POST /exercises/ai/suggest und regenerate bauen ExerciseFormAiPromptContext; Admin-Vorschau nutzt dasselbe Modell.", + ], + }, { "version": "0.8.161", "date": "2026-05-31", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 4d1d844..a9bbd59 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover -**Stand:** 2026-05-30 -**App-Version / DB-Schema:** App **`0.8.160`** (u. a. KI Prompt P1: `load_and_render_ai_prompt`, `ai_prompt_job`); Zielarchitektur-Doku weiterhin **`.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md`**. DB: maßgeblich **`backend/version.py`** → `APP_VERSION`, `DB_SCHEMA_VERSION` (aktuell `20260530069`). +**Stand:** 2026-05-31 +**App-Version / DB-Schema:** App **`0.8.162`** (KI Prompt P1: gemeinsamer Formular-Kontext `ExerciseFormAiPromptContext`, `run_exercise_form_ai_suggestion`); Zielarchitektur **`.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md`**. DB: **`backend/version.py`** → `DB_SCHEMA_VERSION` (aktuell `20260531070`). Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. @@ -89,12 +89,12 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`) - **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions -### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.160**) +### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.162**) - **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan 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 / Job:** **`ai_prompt_runtime`** (`load_and_render_ai_prompt`, `AiPromptUnavailableError`, Rendern aus DB-Zeile fuer Vorschau); **`ai_prompt_job`** — Pydantic **`ExerciseFormAiPromptContext`**, **`resolve_exercise_form_variables`** (Admin-Preview + Schnittstelle fuer weitere Router); **`exercise_ai`** laedt/rendert ueber Laufzeit und ruft OpenRouter -- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`069`** setzt **`default_template`** wo leer; Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus) +- **Kontext / Job:** **`ai_prompt_context`** — **`ExerciseFormAiPromptContext`** (Titel, Ziel, Durchführung, Fokus — ein Modell fuer Admin-Vorschau und Übungs-API); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**, **`resolve_exercise_form_variables`**; **`ai_prompt_runtime`** — Laden/Rendern aus DB; **`exercise_ai`** — OpenRouter nach Rendern +- **DB:** Migration **`067`** **`ai_prompts`**; **`069`** **`default_template`**; **`068`** **`ai_skill_retrieval_profiles`**; **`070`** **`openrouter_model`** (optional pro Prompt, Fallback **`OPENROUTER_MODEL`**) - **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff) - **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas` — **Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`), **`/api/admin/ai-prompts*`** (`routers/ai_prompts_admin.py`), UI **`/admin/ai-prompts`** - **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